Skip to main content

num2words2_core/
lang_lij.rs

1//! Port of `lang_LIJ.py` (Ligurian / Genoese).
2//!
3//! Shape: **self-contained**. `Num2Word_LIJ` subclasses `Num2Word_EU` (which
4//! subclasses `Num2Word_Base`), but it overrides `__init__` with a bare `pass`
5//! — so `Num2Word_Base.__init__` never runs. That has three consequences the
6//! port depends on:
7//!
8//!   * `setup()` is never called, so `self.negword` / `self.pointword` are
9//!     **never set**. LIJ sidesteps this by using its own class attribute
10//!     `MINUS_PREFIX_WORD = "meno "` in `to_cardinal`/`to_ordinal`. Anything
11//!     that reads `self.negword` — i.e. `to_currency` — dies with
12//!     `AttributeError`; the frozen corpus records exactly that for
13//!     `currency:*` with a negative arg. See [`LangLij::to_currency`].
14//!   * `self.cards` / `self.MAXVAL` are never built (LIJ defines no
15//!     `high_numwords`/`mid_numwords`/`low_numwords` either), so `cards`,
16//!     `maxval` and `merge` stay at their trait defaults and are never
17//!     reached. There is **no `OverflowError` path** in this language.
18//!   * The only ceiling is `big_number_to_cardinal`'s explicit
19//!     `NotImplementedError` — see "Ceiling" below.
20//!
21//! Inherited from `Num2Word_EU` — note that `Num2Word_EU` is the *Basque*
22//! converter, so LIJ picks up two Basque-flavoured methods verbatim. This is
23//! not a transcription slip; it is what the key "lij" actually resolves to,
24//! and the corpus confirms it:
25//!   * `to_ordinal_num(number)` → `str(number) + "."`     e.g. 1 → "1."
26//!   * `to_year(val, longval=True)` → `self.to_cardinal(val) + ". urtea"`
27//!     e.g. 2000 → "doamia. urtea" — Basque "urtea" ("the year") glued onto a
28//!     Ligurian numeral, with the stray "." from the ordinal-style format.
29//!     `to_year` resolves `self.to_cardinal` dynamically, so it picks up the
30//!     LIJ override below (including its "meno " sign handling: -500 →
31//!     "meno çinqueçento. urtea").
32//!
33//! # Gender / plural
34//!
35//! LIJ's `to_cardinal(number, gender="m")` and
36//! `to_ordinal(number, gender="m", plural=False)` take extra arguments that
37//! the four ported modes never vary — every reachable call passes the default
38//! `gender="m"` / `plural=False`. The parameters are threaded through here
39//! anyway so the recursion matches Python one-for-one; the `"f"`/plural
40//! branches are unreachable from `to_cardinal`, `to_ordinal`, `to_ordinal_num`
41//! and `to_year`. Likewise the `gender = gender if remainder != 1 else "m"`
42//! re-binds are no-ops under `gender == "m"`, but are kept verbatim.
43//!
44//! `to_currency` is what makes the `"f"` branch live: LIJ picks
45//! `gender = "f" if currency in CURRENCIES_FEM else "m"` and threads it into
46//! `to_cardinal`, so GBP/INR/ITL inflect the *unit* count — 1.0 GBP is
47//! "uña sterliña", 202 GBP is "doe sterliñe", 301 GBP is "træ sterliñe". The
48//! plural branches of `to_ordinal` stay dead: nothing in the currency path
49//! calls it.
50//!
51//! # Ceiling
52//!
53//! `big_number_to_cardinal` splits `number // 10**6` into 3-digit triplets and
54//! zips them against the 10-entry `EXPONENTS` table. `len(triplets) >
55//! len(EXPONENTS)` raises `NotImplementedError("The given number is too
56//! large.")`. `len(str(mils)) > 30` ⇒ 11 triplets, so the wall is at
57//! **10**36** exactly (10**36 - 1 is the largest convertible value, and
58//! `to_cardinal` checks the sign *first*, so -10**36 raises too). Verified
59//! against the interpreter. Note this is `NotImplementedError`, not
60//! `OverflowError`.
61//!
62//! # Faithfully reproduced Python oddities
63//!
64//! Verified against the interpreter; all are preserved verbatim.
65//!
66//! 1. **`big_to_odinal`'s "quattòrze" branch emits a double "e".** The branch
67//!    does `string[:-4] + "orze"` (dropping "òrze", re-adding "orze") and then
68//!    unconditionally appends "eximo", yielding "...quattorze" + "eximo" =
69//!    "...quattorzeeximo". Presumably `[:-4] + "orz"` was meant. Unreachable
70//!    for 14 itself (< 20 goes to `small_to_ordinal` → "quattorzen"), but live
71//!    for every larger number whose cardinal ends in "quattòrze":
72//!    `to_ordinal(114)` == "çentoquattorzeeximo", `to_ordinal(614)` ==
73//!    "seiçentoquattorzeeximo". Not in the corpus — confirmed by hand against
74//!    Python.
75//! 2. The method is named `big_to_odinal` (missing "r") in the Python source.
76//!    Kept as [`big_to_odinal`] so the port greps back to its origin.
77//! 3. The "ëi"/"ei" branch likewise re-adds "ei" before appending "eximo",
78//!    giving "...seieximo" (e.g. `to_ordinal(123456)` ==
79//!    "çentovintitræmiaquattroçentoçinquanteseieximo"). Unlike (1) this one is
80//!    corpus-confirmed, so it is intended (or at least frozen) behaviour.
81//! 4. `to_ordinal(0)` returns "zero" and `to_ordinal(-1)` returns
82//!    "meno primmo" — LIJ never calls `verify_ordinal`, so unlike most
83//!    languages it neither rejects nor crashes on these.
84//! 5. `hundreds_to_cardinal` applies `.replace("ë", "e")` only to the
85//!    *hundreds prefix*, and `thousands_to_cardinal` only to the *thousands
86//!    prefix* — so 600 → "seiçento" (ë stripped) while the remainder keeps
87//!    its diacritic: 123456 → "çentovintitræmiaquattroçentoçinquantesëi".
88//! 6. **Float routing is numeric, not repr-based.** `to_cardinal` tests
89//!    `int(number) != number` and `to_ordinal` tests `number % 1 != 0`, so a
90//!    *whole* float/Decimal (5.0, Decimal("5.00"), 1E+2) takes the integer
91//!    grammar — `to_ordinal(5.0)` == "quinto", never "çinque". And since the
92//!    sign tests are numeric `< 0` comparisons, `-0.0` is **not** negative:
93//!    `to_ordinal(-0.0)` == "zero", `to_year(-0.0)` == "zero. urtea".
94//! 7. **`to_ordinal(2.5)` == "segondo virgola çinque"** — `float_to_words`
95//!    with `ordinal=True` ordinalizes only the *integer prefix*
96//!    (`self.to_ordinal(int(float_number), gender, plural)`); the fractional
97//!    digits stay cardinal and masculine.
98//! 8. **`to_fraction` is Base's, and it reads `self.negword` on the negative
99//!    branch** — which (bare `__init__`, see above) does not exist, so any
100//!    sign-negative fraction raises `AttributeError`. The dispatcher routes
101//!    every `"n/d"` *string* through `to_fraction` regardless of `to=`, so
102//!    `num2words("-3/4", to="year"/"currency"/...)` all raise AttributeError
103//!    too. Non-negative fractions never touch `negword` (the sign is a
104//!    conditional *expression*) and render fine — including `-3/-4` (signs
105//!    cancel) and `-5/1` (short-circuits into LIJ's own `to_cardinal`, which
106//!    uses `MINUS_PREFIX_WORD`, not `negword`).
107//! 9. **`to_cardinal(Decimal("NaN"))` raises `decimal.InvalidOperation`**, not
108//!    ValueError: the very first test `number < 0` is a NaN comparison, which
109//!    the decimal module refuses. See [`LangLij::str_to_number`].
110//!
111//! # Grammatical kwargs (the live `"f"`/plural branches)
112//!
113//! The public Python signatures are `to_cardinal(number, gender="m")` and
114//! `to_ordinal(number, gender="m", plural=False)`; `to_year(val, longval=True)`
115//! is EU's (`longval` accepted and ignored); `to_ordinal_num(number)` and
116//! `to_currency(...)` take nothing extra. LIJ only ever *tests* `gender ==
117//! "f"` (everything else — "m", None, 1, "x" — behaves masculine, no raise)
118//! and uses `plural` for truthiness only, so the `*_kw` hooks normalise
119//! accordingly. `zero` is immune to both gender and plural in the ordinals.
120
121use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
122use crate::strnum::{python_decimal_parse, ParsedNumber};
123use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
124use crate::floatpath::{float2tuple, FloatValue};
125use num_bigint::BigInt;
126use num_traits::{One, Signed, ToPrimitive, Zero};
127use std::collections::HashMap;
128
129/// `MINUS_PREFIX_WORD` — note the trailing space (LIJ concatenates it raw
130/// rather than going through `negword.strip()` like the base class).
131const MINUS_PREFIX_WORD: &str = "meno ";
132
133/// `Num2Word_LIJ.FLOAT_INFIX_WORD` — the decimal separator word, with a space
134/// on each side (`float_to_words` glues it in raw as `prefix + INFIX +
135/// postfix`).
136const FLOAT_INFIX_WORD: &str = " virgola ";
137
138/// Basque suffix inherited from `Num2Word_EU.to_year`. See module docs.
139const YEAR_SUFFIX: &str = ". urtea";
140
141const SMALL_CARDINALS: [&str; 20] = [
142    "zero",
143    "un",
144    "doî",
145    "trei",
146    "quattro",
147    "çinque",
148    "sëi",
149    "sette",
150    "eutto",
151    "neuve",
152    "dexe",
153    "unze",
154    "dozze",
155    "trezze",
156    "quattòrze",
157    "chinze",
158    "sezze",
159    "dïsette",
160    "dixeutto",
161    "dixineuve",
162];
163
164const TENS: [&str; 10] = [
165    "zero",
166    "dexe",
167    "vinti",
168    "trenta",
169    "quaranta",
170    "çinquanta",
171    "sciuscianta",
172    "settanta",
173    "ottanta",
174    "novanta",
175];
176
177const HUNDREDS: [&str; 4] = ["zero", "çento", "duxento", "trexento"];
178
179const THOUSANDS: [&str; 4] = ["zero", "mille", "doamia", "træmia"];
180
181const EXPONENTS: [&str; 10] = [
182    "mion",
183    "miliardo",
184    "bilion",
185    "biliardo",
186    "trilion",
187    "triliardo",
188    "quadrilion",
189    "quadriliardo",
190    "quintilion",
191    "quintiliardo",
192];
193
194const SMALL_ORDINALS: [&str; 20] = [
195    "zero",
196    "primmo",
197    "segondo",
198    "terso",
199    "quarto",
200    "quinto",
201    "sesto",
202    "setten",
203    "otten",
204    "noven",
205    "dexen",
206    "unzen",
207    "dozzen",
208    "trezzen",
209    "quattorzen",
210    "chinzen",
211    "sezzen",
212    "dïsetten",
213    "dixotten",
214    "dixinoven",
215];
216
217// ---- currency data ------------------------------------------------------
218//
219// `Num2Word_LIJ` declares its **own** class-body `CURRENCY_FORMS`, so it does
220// not read (or get mutated by) the shared `Num2Word_EUR` dict that
221// `Num2Word_EN.__init__` rewrites in place. Confirmed against the live
222// interpreter: LIJ's MRO is `Num2Word_LIJ -> Num2Word_EU -> Num2Word_Base`,
223// `Num2Word_EN` is not on it, and `LIJ.CURRENCY_FORMS is EU.CURRENCY_FORMS` is
224// False. The literal below therefore *is* the runtime table — no EN overlay,
225// and none of EN's ~24 extra codes (AED/KRW/BRL/…) are visible here.
226
227/// Python's module-level `CENTS`.
228const CENTS: [&str; 2] = ["citto", "citti"];
229/// Python's `DOLLAR[0]` — the unit half of `DOLLAR = (("dòllao","dòllai"), CENTS)`.
230const DOLLAR: [&str; 2] = ["dòllao", "dòllai"];
231/// Python's `FRANC[0]`.
232const FRANC: [&str; 2] = ["franco", "franchi"];
233
234/// Python's `CURRENCIES_FEM = {"GBP", "INR", "ISK", "ITL"}` — the codes whose
235/// *unit* count is feminine.
236///
237/// "ISK" is dead weight: it has no `CURRENCY_FORMS` entry, and the forms
238/// lookup raises `NotImplementedError` before `gender` is ever computed. Kept
239/// so the set matches the source one-for-one.
240const CURRENCIES_FEM: [&str; 4] = ["GBP", "INR", "ISK", "ITL"];
241
242/// `Num2Word_LIJ.CURRENCY_FORMS`, verbatim.
243fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
244    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
245    m.insert("AUD", CurrencyForms::new(&DOLLAR, &CENTS));
246    m.insert("CAD", CurrencyForms::new(&DOLLAR, &CENTS));
247    m.insert("CHF", CurrencyForms::new(&FRANC, &CENTS));
248    m.insert("CNY", CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"]));
249    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &CENTS));
250    m.insert("FRF", CurrencyForms::new(&FRANC, &CENTS));
251    m.insert(
252        "GBP",
253        CurrencyForms::new(&["sterliña", "sterliñe"], &["penny", "pence"]),
254    );
255    m.insert("HKD", CurrencyForms::new(&DOLLAR, &CENTS));
256    m.insert("INR", CurrencyForms::new(&["rupia", "rupie"], &["paisa", "paise"]));
257    m.insert("ITL", CurrencyForms::new(&["lia", "lie"], &CENTS));
258    m.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
259    m.insert("SGD", CurrencyForms::new(&DOLLAR, &CENTS));
260    m.insert("NZD", CurrencyForms::new(&DOLLAR, &CENTS));
261    m.insert("USD", CurrencyForms::new(&DOLLAR, &CENTS));
262    m
263}
264
265/// `Num2Word_LIJ.CURRENCY_ADJECTIVES`, verbatim.
266///
267/// Each entry is a **pair** (singular, plural) that LIJ zips against the two
268/// unit forms, so this cannot go through the `currency_adjective` trait hook —
269/// that returns a single `&str` for `currency::prefix_currency`, which LIJ does
270/// not use. See [`LangLij::to_currency`].
271fn build_currency_adjectives() -> HashMap<&'static str, (&'static str, &'static str)> {
272    [
273        ("AUD", ("australian", "australien")),
274        ("CAD", ("canadeise", "canadeixi")),
275        ("CHF", ("svissero", "svisseri")),
276        ("FRF", ("franseise", "franseixi")),
277        ("HKD", ("de Hong Kong", "de Hong Kong")),
278        ("ITL", ("italiaña", "italiañe")),
279        ("SGD", ("de Scingapô", "de Scingapô")),
280        ("NZD", ("neozelandeise", "neozelandeixi")),
281        ("USD", ("american", "americhen")),
282    ]
283    .into_iter()
284    .collect()
285}
286
287/// Python `tuple[i]` past the end. LIJ's own table always has two forms, so
288/// this is unreachable — but the exception *type* is mapped rather than
289/// panicking, in case the table is ever edited.
290fn tuple_index_error() -> N2WError {
291    N2WError::Index("tuple index out of range".into())
292}
293
294/// Python `s[:-n]`, counting **characters** not bytes (the tables are full of
295/// ç/ë/î/ò/æ/ï). Python clamps rather than panicking when `n > len(s)`.
296fn drop_last(s: &str, n: usize) -> String {
297    let total = s.chars().count();
298    s.chars().take(total.saturating_sub(n)).collect()
299}
300
301/// Python `s[-1]`. Returns `None` only for an empty string, where Python would
302/// raise `IndexError`; every call site here is guarded by `number >= 20`, so
303/// the string is never empty and the `None` arm is unreachable.
304fn last_char(s: &str) -> Option<char> {
305    s.chars().next_back()
306}
307
308/// Port of `small_to_cardinal`. Python asserts `number < 20`.
309fn small_to_cardinal(number: u32, gender: &str) -> String {
310    debug_assert!(number < 20);
311    let mut string = SMALL_CARDINALS[number as usize].to_string();
312    if gender == "f" {
313        // Sequential `if`s in Python, not `elif` — mutually exclusive on
314        // `number`, so the distinction is moot, but kept as-is.
315        if number == 1 {
316            string = drop_last(&string, 1) + "ña";
317        }
318        if number == 2 {
319            string = drop_last(&string, 1) + "e";
320        }
321        if number == 3 {
322            string = drop_last(&string, 2) + "æ";
323        }
324    }
325    string
326}
327
328/// Port of `tens_to_cardinal`. Python asserts `20 <= number < 100`.
329fn tens_to_cardinal(number: u32, gender: &str) -> String {
330    debug_assert!((20..100).contains(&number));
331    let (tens, units) = (number / 10, number % 10);
332    let mut string = TENS[tens as usize].to_string();
333    // Bare tens
334    if units == 0 {
335        return string;
336    }
337    // Tens + units, with phonetic contractions
338    if tens != 2 {
339        string = drop_last(&string, 1) + "e";
340    }
341    if units == 1 || units == 8 {
342        string = drop_last(&string, 1);
343    }
344    let gender = if units != 1 { gender } else { "m" };
345    string + &small_to_cardinal(units, gender)
346}
347
348/// Port of `hundreds_to_cardinal`. Python asserts `100 <= number < 1000`.
349fn hundreds_to_cardinal(number: u32, gender: &str) -> Result<String> {
350    debug_assert!((100..1000).contains(&number));
351    let (hundreds, remainder) = (number / 100, number % 100);
352    let string = if (hundreds as usize) < HUNDREDS.len() {
353        let s = HUNDREDS[hundreds as usize].to_string();
354        if (80..=89).contains(&remainder) {
355            drop_last(&s, 1)
356        } else {
357            s
358        }
359    } else {
360        let s = small_to_cardinal(hundreds, "m").replace('ë', "e");
361        // Phonetic adjustment
362        if (80..=89).contains(&remainder) {
363            s + "çent"
364        } else {
365            s + "çento"
366        }
367    };
368    if remainder == 0 {
369        Ok(string)
370    } else {
371        let gender = if remainder != 1 { gender } else { "m" };
372        Ok(string + &to_cardinal_gender(&BigInt::from(remainder), gender)?)
373    }
374}
375
376/// Port of `thousands_to_cardinal`. Python asserts `1000 <= number < 10**6`.
377fn thousands_to_cardinal(number: u32, gender: &str) -> Result<String> {
378    debug_assert!((1000..1_000_000).contains(&number));
379    let (thousands, remainder) = (number / 1000, number % 1000);
380    let string = if (thousands as usize) < THOUSANDS.len() {
381        THOUSANDS[thousands as usize].to_string()
382    } else {
383        to_cardinal_gender(&BigInt::from(thousands), "m")?
384            // fossilised forms doa- and træ-
385            .replace("doî", "doa")
386            .replace("trei", "træ")
387            // no length markers on "sëi"
388            .replace('ë', "e")
389            // no grave accent on "quattòrze" since it isn't the main stress
390            .replace('ò', "o")
391            + "mia"
392    };
393    if remainder == 0 {
394        Ok(string)
395    } else {
396        let gender = if remainder != 1 { gender } else { "m" };
397        Ok(string + &to_cardinal_gender(&BigInt::from(remainder), gender)?)
398    }
399}
400
401/// Port of `big_number_to_cardinal`. Python asserts `number >= 10**6`.
402///
403/// `mils` is unbounded (up to 10**30), so it stays a `BigInt` and is split via
404/// its decimal string exactly as Python does with `str(mils)[::-1]`.
405fn big_number_to_cardinal(number: &BigInt, gender: &str) -> Result<String> {
406    debug_assert!(*number >= BigInt::from(1_000_000u32));
407    let million = BigInt::from(1_000_000u32);
408    let mils = number / &million;
409    let remainder = number % &million;
410
411    // `mils >= 1` here, so `to_string()` never carries a sign or leading zero.
412    let rev_mils: Vec<char> = mils.to_string().chars().rev().collect();
413    let mut triplets: Vec<u32> = Vec::new();
414    let mut i = 0;
415    while i < rev_mils.len() {
416        let end = (i + 3).min(rev_mils.len());
417        // Python: int(rev_mils[i:i+3][::-1]) — re-reverse the 3-char window.
418        // At most 3 digits, so the u32 parse is provably in range.
419        let chunk: String = rev_mils[i..end].iter().rev().collect();
420        triplets.push(chunk.parse::<u32>().expect("<=3 decimal digits"));
421        i += 3;
422    }
423
424    if triplets.len() > EXPONENTS.len() {
425        return Err(N2WError::NotImplemented("The given number is too large.".into()));
426    }
427
428    let mut string = if !remainder.is_zero() {
429        let gender = if !remainder.is_one() { gender } else { "m" };
430        to_cardinal_gender(&remainder, gender)?
431    } else {
432        String::new()
433    };
434
435    // Python `zip` stops at the shorter sequence; `triplets.len() <=
436    // EXPONENTS.len()` is guaranteed above, so it stops at `triplets`.
437    for (triplet, exponent) in triplets.iter().zip(EXPONENTS.iter()) {
438        let prefix = if *triplet == 0 {
439            String::new()
440        } else if *triplet == 1 {
441            format!("un {}", exponent)
442        } else {
443            let head = to_cardinal_gender(&BigInt::from(*triplet), "m")? + " ";
444            if exponent.ends_with('n') {
445                head + &drop_last(exponent, 1) + "in"
446            } else {
447                head + &drop_last(exponent, 1) + "i"
448            }
449        };
450        if !prefix.is_empty() {
451            if !string.is_empty() {
452                if string.contains(" e ") {
453                    string = format!("{}, {}", prefix, string);
454                } else {
455                    string = format!("{} e {}", prefix, string);
456                }
457            } else {
458                string = prefix;
459            }
460        }
461    }
462    Ok(string)
463}
464
465/// Port of `to_cardinal(number, gender="m")`.
466///
467/// The `int(number) != number` float branch is dead for integer input and is
468/// omitted along with `float_to_words`.
469fn to_cardinal_gender(number: &BigInt, gender: &str) -> Result<String> {
470    if number.is_negative() {
471        // Sign is handled *before* the size dispatch, so -10**36 raises
472        // NotImplementedError just like +10**36.
473        return Ok(MINUS_PREFIX_WORD.to_string() + &to_cardinal_gender(&(-number), gender)?);
474    }
475    // Each branch below 10**6 is bounded by its own guard, so the u32
476    // narrowing is provably lossless. Above that we stay in BigInt.
477    if *number < BigInt::from(20u32) {
478        Ok(small_to_cardinal(number.to_u32().expect("< 20"), gender))
479    } else if *number < BigInt::from(100u32) {
480        Ok(tens_to_cardinal(number.to_u32().expect("< 100"), gender))
481    } else if *number < BigInt::from(1000u32) {
482        hundreds_to_cardinal(number.to_u32().expect("< 1000"), gender)
483    } else if *number < BigInt::from(1_000_000u32) {
484        thousands_to_cardinal(number.to_u32().expect("< 10**6"), gender)
485    } else {
486        big_number_to_cardinal(number, gender)
487    }
488}
489
490/// Port of `small_to_ordinal`. Python asserts `number < 20`.
491fn small_to_ordinal(number: u32, gender: &str, plural: bool) -> String {
492    debug_assert!(number < 20);
493    let mut string = SMALL_ORDINALS[number as usize].to_string();
494    if gender == "f" && string != "zero" {
495        if string.ends_with('o') {
496            string = drop_last(&string, 1) + "a";
497        } else {
498            string = drop_last(&string, 1) + "ña";
499        }
500    }
501    // Runs against the *gender-adjusted* string, matching Python's ordering.
502    if plural && string != "zero" {
503        if string.ends_with('o') {
504            string = drop_last(&string, 1) + "i";
505        } else if string.ends_with('a') {
506            string = drop_last(&string, 1) + "e";
507        }
508    }
509    string
510}
511
512/// Port of `big_to_odinal` — Python's spelling, missing the "r". Asserts
513/// `number >= 20`.
514///
515/// Note Python calls `self.to_cardinal(number)` with **no** gender argument,
516/// so the stem is always masculine regardless of the `gender` parameter;
517/// `gender` only drives the final suffix swap.
518fn big_to_odinal(number: &BigInt, gender: &str, plural: bool) -> Result<String> {
519    debug_assert!(*number >= BigInt::from(20u32));
520    let mut string = to_cardinal_gender(number, "m")?;
521
522    // Adjust diacritics and perform contractions
523    if string.ends_with("ëi") || string.ends_with("ei") {
524        string = drop_last(&string, 2) + "ei";
525    } else if string.ends_with("quattòrze") {
526        // Drops "òrze" and re-adds "orze"; the unconditional "eximo" below
527        // then yields "...quattorzeeximo". Faithful — see module docs (1).
528        string = drop_last(&string, 4) + "orze";
529    } else if string.ends_with('î') {
530        string = drop_last(&string, 1) + "i";
531    } else if matches!(last_char(&string), Some(c) if "oeai".contains(c)) {
532        string = drop_last(&string, 1);
533    }
534    string += "eximo";
535
536    // Additional phonetic adjustments
537    if string.ends_with("mieximo") {
538        string = drop_last(&string, 5) + "lleximo";
539    } else if string.ends_with("oeutteximo") {
540        string = drop_last(&string, 10) + "otteximo";
541    } else if string.ends_with("eutteximo") {
542        string = drop_last(&string, 9) + "otteximo";
543    } else if string.ends_with("neuveximo") {
544        string = drop_last(&string, 8) + "oveximo";
545    }
546
547    // Gender/number adjustment
548    if gender == "f" {
549        if plural {
550            string = drop_last(&string, 1) + "e";
551        } else {
552            string = drop_last(&string, 1) + "a";
553        }
554    } else if plural {
555        string = drop_last(&string, 1) + "i";
556    }
557    Ok(string)
558}
559
560/// Port of `to_ordinal(number, gender="m", plural=False)`.
561///
562/// The `number % 1 != 0` float branch is dead for integer input. Unlike most
563/// languages, LIJ never calls `verify_ordinal`, so negatives and 0 are fine.
564fn to_ordinal_gender(number: &BigInt, gender: &str, plural: bool) -> Result<String> {
565    if number.is_negative() {
566        return Ok(MINUS_PREFIX_WORD.to_string() + &to_ordinal_gender(&(-number), gender, plural)?);
567    }
568    if *number < BigInt::from(20u32) {
569        Ok(small_to_ordinal(number.to_u32().expect("< 20"), gender, plural))
570    } else {
571        big_to_odinal(number, gender, plural)
572    }
573}
574
575/// The `gender=` kwarg, normalised. LIJ only ever tests `gender == "f"` (in
576/// `small_to_cardinal`, `small_to_ordinal`, `big_to_odinal`); any other value
577/// — "m", an explicit None, an int, an arbitrary string — falls through every
578/// `==` test and behaves exactly like the default "m", with no raise anywhere.
579fn kw_gender(kw: &Kwargs) -> &'static str {
580    if kw.str("gender") == Some("f") {
581        "f"
582    } else {
583        "m"
584    }
585}
586
587/// Python truthiness of the `plural=` kwarg (`if plural and ...` /
588/// `elif plural:` — never an identity or equality test). Absent means the
589/// signature default `False`; an explicit `plural=None` is falsy too.
590fn kw_truthy(v: Option<&KwVal>) -> bool {
591    match v {
592        Some(KwVal::Bool(b)) => *b,
593        Some(KwVal::Int(i)) => *i != 0,
594        Some(KwVal::Str(s)) => !s.is_empty(),
595        Some(KwVal::List(l)) => !l.is_empty(),
596        Some(KwVal::None) | None => false,
597    }
598}
599
600/// Python `-value` on the float/Decimal, for the `to_cardinal(-number)` /
601/// `to_ordinal(-number)` sign recursion.
602fn negated(value: &FloatValue) -> FloatValue {
603    match value {
604        FloatValue::Float { value, precision } => FloatValue::Float {
605            value: -value,
606            precision: *precision,
607        },
608        FloatValue::Decimal { value, precision } => FloatValue::Decimal {
609            value: -value.clone(),
610            precision: *precision,
611        },
612    }
613}
614
615/// Port of `Num2Word_LIJ.float_to_words(float_number, gender="m",
616/// plural=False, ordinal=False)` for a **non-negative, non-whole** value.
617///
618/// ```python
619/// def float_to_words(self, float_number, gender="m", plural=False, ordinal=False):
620///     if ordinal:
621///         prefix = self.to_ordinal(int(float_number), gender, plural)
622///     else:
623///         prefix = self.to_cardinal(int(float_number), gender)
624///     float_part = str(float_number).split(".")[1]
625///     postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])
626///     return prefix + Num2Word_LIJ.FLOAT_INFIX_WORD + postfix
627/// ```
628///
629/// The sign never reaches this function: both callers (`to_cardinal`,
630/// `to_ordinal`) strip it first and recurse, exactly like Python — see the
631/// entry hooks. Three things the port depends on:
632///
633/// 1. **Only the integer prefix is ordinalized** (`ordinal=True` threads
634///    `gender`/`plural` into `to_ordinal(int(...))`); the per-digit postfix is
635///    always the bare masculine `self.to_cardinal(int(c))` — hence
636///    `to_ordinal(3.25)` == "terso virgola doî çinque".
637/// 2. **The fractional digits are `str(value).split(".")[1]`** — the exact
638///    shortest-repr digits (float) or the exact Decimal digits *with trailing
639///    zeros* (Decimal), one word per digit. There is no rounding heuristic and
640///    `self.precision`/`precision=` are never read. `float2tuple`
641///    reconstructs exactly those digits: its `post`, zero-padded on the left
642///    to the repr-derived precision, equals `str(value).split(".")[1]` in
643///    every corpus case, and keeping the raw f64 arithmetic preserves the
644///    load-bearing artefacts (e.g. `2.675` -> "675").
645/// 3. **The infix is glued raw**: `prefix + " virgola " + postfix`, no
646///    `title`/`pointword` involvement (`pointword` is never even set — bare
647///    `__init__`).
648fn float_to_words(value: &FloatValue, gender: &str, plural: bool, ordinal: bool) -> Result<String> {
649    let precision = value.precision() as usize;
650    let (pre, post) = float2tuple(value);
651
652    // `float_part = str(value).split(".")[1]` — `post` (always < 10**precision,
653    // so at most `precision` digits) zero-padded on the left. This carries
654    // the Decimal arm's trailing zeros too: Decimal("1.10") -> post 10 ->
655    // "10", Decimal("5.00") -> post 0 -> "00".
656    let post_str = post.to_string();
657    let float_part = format!(
658        "{}{}",
659        "0".repeat(precision.saturating_sub(post_str.chars().count())),
660        post_str
661    );
662
663    // `prefix = self.to_ordinal(int(v), gender, plural)` when ordinal, else
664    // `self.to_cardinal(int(v), gender)`.
665    let prefix = if ordinal {
666        to_ordinal_gender(&pre, gender, plural)?
667    } else {
668        to_cardinal_gender(&pre, gender)?
669    };
670
671    // `postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])`
672    // — one masculine cardinal per fractional digit.
673    let mut digits: Vec<String> = Vec::with_capacity(float_part.chars().count());
674    for ch in float_part.chars() {
675        let d = ch.to_digit(10).ok_or_else(|| {
676            N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
677        })?;
678        digits.push(to_cardinal_gender(&BigInt::from(d), "m")?);
679    }
680    let postfix = digits.join(" ");
681
682    Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, postfix))
683}
684
685pub struct LangLij {
686    currency_forms: HashMap<&'static str, CurrencyForms>,
687    currency_adjectives: HashMap<&'static str, (&'static str, &'static str)>,
688}
689
690impl LangLij {
691    pub fn new() -> Self {
692        LangLij {
693            // Built once here, never per call. These are class-body constants
694            // in Python; rebuilding them inside `to_currency` would make the
695            // port slower than the interpreter it replaces.
696            currency_forms: build_currency_forms(),
697            currency_adjectives: build_currency_adjectives(),
698        }
699    }
700}
701
702impl Default for LangLij {
703    fn default() -> Self {
704        LangLij::new()
705    }
706}
707
708impl Lang for LangLij {
709    /// This language's own `to_currency(currency=...)` default,
710    /// read from the live Python signature. Only 44 of 156 use EUR.
711    fn default_currency(&self) -> &str {
712        "EUR"
713    }
714
715    /// This language's own `to_currency(separator=...)` default,
716    /// read from the live Python signature. Base's is ",", but only
717    /// 36 of 149 languages actually use it — most default to " " or a
718    /// conjunction, so inheriting Base's comma silently corrupts them.
719    fn default_separator(&self) -> &str {
720        " e"
721    }
722
723    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
724        to_cardinal_gender(value, "m")
725    }
726
727    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
728        to_ordinal_gender(value, "m", false)
729    }
730
731    /// Inherited from `Num2Word_EU`: `str(number) + "."`.
732    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
733        Ok(format!("{}.", value))
734    }
735
736    /// Inherited from `Num2Word_EU`: `self.to_cardinal(val) + ". urtea"` —
737    /// the Basque suffix leaks into Ligurian. See module docs.
738    fn to_year(&self, value: &BigInt) -> Result<String> {
739        Ok(self.to_cardinal(value)? + YEAR_SUFFIX)
740    }
741
742    /// Port of the non-integral branch of `to_cardinal` +
743    /// [`Num2Word_LIJ.float_to_words`]. **LIJ does not use the base float
744    /// path** — it never calls `float2tuple`/`pointword`/`self.precision`, and
745    /// (since `__init__` is a bare `pass`) `pointword` is never even set, so
746    /// the inherited `to_cardinal_float` would be unreachable/broken. Instead:
747    ///
748    /// ```python
749    /// def to_cardinal(self, number, gender="m"):
750    ///     if number < 0:
751    ///         string = self.MINUS_PREFIX_WORD + self.to_cardinal(-number, gender)
752    ///     elif int(number) != number:
753    ///         string = self.float_to_words(number, gender)
754    ///     ...
755    ///
756    /// def float_to_words(self, float_number, gender="m", plural=False, ordinal=False):
757    ///     prefix = self.to_cardinal(int(float_number), gender)  # ordinal=False here
758    ///     float_part = str(float_number).split(".")[1]
759    ///     postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])
760    ///     return prefix + Num2Word_LIJ.FLOAT_INFIX_WORD + postfix
761    /// ```
762    ///
763    /// One consequence the port depends on (the digit/infix mechanics live on
764    /// [`float_to_words`]):
765    ///
766    /// **The sign is handled by `to_cardinal`'s recursion, not by
767    /// `float_to_words`.** `int(-0.5) == 0` carries no minus, so relying on
768    /// the integer prefix would drop the sign for `|value| < 1`. LIJ instead
769    /// prepends `MINUS_PREFIX_WORD` and recurses on `-value` — hence
770    /// `-0.5` -> "meno zero virgola çinque". Reproduced by negating the
771    /// `FloatValue` and recursing before touching the digits. (Only non-whole
772    /// values reach this method, so the numeric `number < 0` test and the
773    /// sign-bit `is_negative()` agree — there is no `-0.0` to mis-route.)
774    fn to_cardinal_float(
775        &self,
776        value: &FloatValue,
777        _precision_override: Option<u32>,
778    ) -> Result<String> {
779        // `if number < 0: MINUS_PREFIX_WORD + to_cardinal(-number)`. The
780        // negated value is still non-integral, so it re-enters this same
781        // method's positive branch. `precision=` never reaches float_to_words,
782        // so it is not threaded through.
783        if value.is_negative() {
784            return Ok(format!(
785                "{}{}",
786                MINUS_PREFIX_WORD,
787                self.to_cardinal_float(&negated(value), None)?
788            ));
789        }
790
791        // `float_to_words(value, gender="m")`. `value >= 0` here.
792        float_to_words(value, "m", false, false)
793    }
794
795    /// `to_cardinal(float/Decimal, gender=...)` — the kwargs entry for the
796    /// cardinal float path. Python's `to_cardinal` threads `gender` through
797    /// all three branches (sign recursion, whole -> integer grammar,
798    /// non-whole -> `float_to_words(number, gender)`); the whole-value test
799    /// is the numeric `int(number) != number`, so Decimal("5.00") with
800    /// `gender="f"` is just "çinque" and `-0.0` is "zero".
801    fn to_cardinal_float_kw(
802        &self,
803        value: &FloatValue,
804        _precision_override: Option<u32>,
805        kw: &Kwargs,
806    ) -> Result<String> {
807        if !kw.only(&["gender"]) {
808            return Err(N2WError::Fallback("kwargs".into()));
809        }
810        let gender = kw_gender(kw);
811        if let Some(i) = value.as_whole_int() {
812            return to_cardinal_gender(&i, gender);
813        }
814        if value.is_negative() {
815            return Ok(format!(
816                "{}{}",
817                MINUS_PREFIX_WORD,
818                float_to_words(&negated(value), gender, false, false)?
819            ));
820        }
821        float_to_words(value, gender, false, false)
822    }
823
824    // `cardinal_float_entry` stays at the trait default: LIJ's `to_cardinal`
825    // routes on the numeric `int(number) != number` (whole -> integer
826    // grammar, else float_to_words), which is exactly base semantics — and
827    // its sign test `number < 0` is numeric too, matching the default's
828    // whole-value handling of -0.0 (as_whole_int(-0.0) == 0 -> "zero").
829
830    /// `to_ordinal(float/Decimal)`:
831    ///
832    /// ```python
833    /// def to_ordinal(self, number, gender="m", plural=False):
834    ///     if number < 0:
835    ///         return self.MINUS_PREFIX_WORD + self.to_ordinal(-number, gender, plural)
836    ///     elif number % 1 != 0:
837    ///         return self.float_to_words(number, gender, plural, ordinal=True)
838    ///     elif number < 20: ...  # whole floats take the integer grammar
839    ///     else: ...
840    /// ```
841    ///
842    /// Whole values — `5.0`, `Decimal("5.00")`, `1E+2`, and whole negatives —
843    /// land in the ordinal *integer* grammar (`to_ordinal(5.0)` == "quinto",
844    /// `to_ordinal(-1000000.0)` == "meno un mioneximo"); checking
845    /// `as_whole_int` first reproduces that, and also keeps `-0.0` (numerically
846    /// not `< 0`) at plain "zero". Non-whole values go through
847    /// `float_to_words(..., ordinal=True)`, which ordinalizes only the integer
848    /// prefix: `to_ordinal(2.5)` == "segondo virgola çinque", `to_ordinal(-1.5)`
849    /// == "meno primmo virgola çinque".
850    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
851        if let Some(n) = value.as_whole_int() {
852            // Whole (sign included): identical to the integer entry, since
853            // Python's sign recursion re-lands on the same whole-value branch.
854            return to_ordinal_gender(&n, "m", false);
855        }
856        // Non-whole: `number < 0` first (numeric == sign-bit here), then
857        // `float_to_words(number, "m", False, ordinal=True)`.
858        if value.is_negative() {
859            return Ok(format!(
860                "{}{}",
861                MINUS_PREFIX_WORD,
862                float_to_words(&negated(value), "m", false, true)?
863            ));
864        }
865        float_to_words(value, "m", false, true)
866    }
867
868    /// `to_ordinal_num(float/Decimal)` — EU's `str(number) + "."`, floats
869    /// included: `1.5` -> "1.5.", `-0.0` -> "-0.0.", `1e+16` -> "1e+16.",
870    /// `Decimal("1E+2")` -> "1E+2.". `repr_str` is Python's `str(value)`.
871    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
872        Ok(format!("{}.", repr_str))
873    }
874
875    /// `to_year(float/Decimal)` — EU's `self.to_cardinal(val) + ". urtea"`,
876    /// with `self.to_cardinal` resolving to LIJ's override: whole floats take
877    /// the integer grammar, non-whole go through `float_to_words`, and `-0.0`
878    /// is "zero. urtea" (numeric sign test). All of that is exactly the
879    /// (default) `cardinal_float_entry`, so delegate and suffix.
880    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
881        Ok(self.cardinal_float_entry(value, None)? + YEAR_SUFFIX)
882    }
883
884    /// `converter.str_to_number` is Base's bare `Decimal(value)` — LIJ does
885    /// not override it. But the very next thing every string mode does is
886    /// `self.to_cardinal(number)` (directly, or via `to_year`), and LIJ's
887    /// first test there is the comparison `number < 0`, which on a NaN
888    /// Decimal raises `decimal.InvalidOperation` — *not* the ValueError that
889    /// `int(NaN)` would give in base's integer path. Raising it here (the
890    /// parse and the comparison are back-to-back in the dispatcher, with the
891    /// same observable outcome) keeps `num2words("NaN", lang="lij")` on
892    /// InvalidOperation. "NaN" has no digits, so the dispatcher's
893    /// digits-present sentence fallback can never swallow it; NaN-with-payload
894    /// strings ("NaN123") fall back to the Python original either way.
895    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
896        match python_decimal_parse(s)? {
897            ParsedNumber::NaN => Err(N2WError::Custom {
898                module: "decimal",
899                class: "InvalidOperation",
900                // The message CPython's decimal module gives a NaN comparison.
901                msg: "[<class 'decimal.InvalidOperation'>]".into(),
902            }),
903            other => Ok(other),
904        }
905    }
906
907    /// `Num2Word_Base.to_fraction`, re-ported here because its negative
908    /// branch reads `self.negword` — which `Num2Word_LIJ` never sets (bare
909    /// `__init__`, no class attribute), so a sign-negative fraction raises
910    /// `AttributeError`. Everything else is the base default verbatim:
911    ///
912    /// ```python
913    /// if denominator == 0: raise ZeroDivisionError("denominator must not be zero")
914    /// if denominator == 1 or numerator == 0: return self.to_cardinal(numerator)
915    /// is_negative = (numerator < 0) ^ (denominator < 0)
916    /// abs_n = abs(int(numerator)); abs_d = abs(int(denominator))
917    /// sign = "%s " % self.negword.strip() if is_negative else ""   # <- raises
918    /// num_word = self.to_cardinal(abs_n)
919    /// den_word = self.to_ordinal(abs_d)
920    /// if abs_n != 1: den_word = den_word + "s"
921    /// return sign + num_word + " " + den_word
922    /// ```
923    ///
924    /// Ordering matters: `sign` is a conditional *expression*, so `negword`
925    /// is only touched when `is_negative` — `-3/-4` (signs cancel) renders
926    /// "trei quartos", `0/-5` short-circuits to "zero", and `-5/1`
927    /// short-circuits into LIJ's own `to_cardinal` ("meno çinque", via
928    /// `MINUS_PREFIX_WORD`). The raise happens *before* `num_word`/`den_word`
929    /// are computed, so `-10**36/2` is AttributeError, not NotImplementedError.
930    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
931        if denominator.is_zero() {
932            return Err(N2WError::ZeroDivision("denominator must not be zero".into()));
933        }
934        if denominator.is_one() || numerator.is_zero() {
935            return self.to_cardinal(numerator);
936        }
937        let is_negative = numerator.is_negative() ^ denominator.is_negative();
938        if is_negative {
939            return Err(N2WError::Attribute(
940                "'Num2Word_LIJ' object has no attribute 'negword'".into(),
941            ));
942        }
943        let abs_n = numerator.abs();
944        let abs_d = denominator.abs();
945        let num_word = self.to_cardinal(&abs_n)?;
946        let mut den_word = self.to_ordinal(&abs_d)?;
947        if !abs_n.is_one() {
948            den_word += "s"; // base's naive plural, kept verbatim
949        }
950        Ok(format!("{} {}", num_word, den_word))
951    }
952
953    // ---- grammatical kwargs ----------------------------------------------
954
955    /// `to_cardinal(number, gender="m")` — the only kwarg is `gender`.
956    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
957        if !kw.only(&["gender"]) {
958            return Err(N2WError::Fallback("kwargs".into()));
959        }
960        to_cardinal_gender(value, kw_gender(kw))
961    }
962
963    /// `to_ordinal(number, gender="m", plural=False)`.
964    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
965        if !kw.only(&["gender", "plural"]) {
966            return Err(N2WError::Fallback("kwargs".into()));
967        }
968        to_ordinal_gender(value, kw_gender(kw), kw_truthy(kw.get("plural")))
969    }
970
971    /// EU's `to_year(val, longval=True)` — `longval` is accepted and then
972    /// never read (the body is unconditionally `to_cardinal(val) + ". urtea"`),
973    /// so any value, of any type, is a no-op.
974    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
975        if !kw.only(&["longval"]) {
976            return Err(N2WError::Fallback("kwargs".into()));
977        }
978        self.to_year(value)
979    }
980
981    // `to_ordinal_num` (EU) and `to_currency` (LIJ) accept no extra kwargs;
982    // the trait defaults' empty-bag guard already reproduces Python's
983    // TypeError (via the NotImplemented -> Python fallback) for anything else.
984
985    // ---- currency -------------------------------------------------------
986    //
987    // `Num2Word_LIJ` defines `CURRENCY_FORMS`, `CURRENCY_ADJECTIVES`,
988    // `pluralize` and `to_currency`. Everything else on the currency path is
989    // `Num2Word_Base`'s and is already the trait default:
990    //
991    //   * `CURRENCY_PRECISION` is never redeclared, so it is Base's empty dict
992    //     and `.get(code, 100)` is *always* 100 — hence no `currency_precision`
993    //     override. This is why JPY here keeps a 100-cent "sen" subunit
994    //     (`12.34 JPY` -> "dozze yen e trentequattro sen") instead of taking
995    //     the 0-decimal path, and why `cheque:JPY` prints "56/100".
996    //   * `_money_verbose`/`_cents_verbose`/`_cents_terse` are Base's and route
997    //     to `self.to_cardinal`, which the trait default already does.
998    //   * `to_cheque` is Base's verbatim; `default_to_cheque` reproduces it.
999    //     It reads `cr1[-1]` (plural unit) and `_money_verbose` (masculine
1000    //     `to_cardinal`, no gender threading), and signs with a literal
1001    //     "MINUS " rather than `negword` — so unlike `to_currency`, cheques on
1002    //     negative values do *not* raise: `to_cheque(-5.25, "USD")` ==
1003    //     "MINUS ÇINQUE AND 25/100 DÒLLAI".
1004    //   * `cardinal_from_decimal` stays at its default: LIJ's `to_currency`
1005    //     always passes `keep_precision=False`, so fractional cents never
1006    //     reach it (1.011 USD quantizes to 1.01 -> "un dòllao e un citto").
1007
1008    fn lang_name(&self) -> &str {
1009        "Num2Word_LIJ"
1010    }
1011
1012    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1013        self.currency_forms.get(code)
1014    }
1015
1016    /// `Num2Word_LIJ.pluralize`: `forms[0 if n == 1 else 1]`.
1017    ///
1018    /// Python indexes the tuple directly, so a one-form entry with `n != 1`
1019    /// would raise IndexError; every LIJ entry has two forms.
1020    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1021        let form = if n.is_one() { 0 } else { 1 };
1022        forms.get(form).cloned().ok_or_else(tuple_index_error)
1023    }
1024
1025    /// Port of `Num2Word_LIJ.to_currency`, which replaces Base's outright.
1026    ///
1027    /// Three things make it diverge sharply from `default_to_currency`:
1028    ///
1029    /// 1. **`parse_currency_parts(val)` is called with no keyword arguments**,
1030    ///    so the *module* defaults apply — `is_int_with_cents=True`,
1031    ///    `keep_precision=False`, `divisor=100`. Base passes
1032    ///    `is_int_with_cents=False` and branches on `isinstance(val, int)` to
1033    ///    suppress cents for true ints; LIJ does neither. A plain `int` is
1034    ///    therefore read as a **count of cents**: `to_currency(100)` is "un
1035    ///    euro e zero citti" and `to_currency(1)` is "zero euro e un citto",
1036    ///    not "un euro". The `Int`/`Decimal` split still matters for how the
1037    ///    value is split, but not for whether cents appear — LIJ always prints
1038    ///    them, so `has_decimal` is unused here (`Decimal("5")` and
1039    ///    `Decimal("5.00")` both give "çinque dòllai e zero citti").
1040    /// 2. **The divisor is hardcoded to 100**, ignoring `CURRENCY_PRECISION`
1041    ///    entirely — moot for LIJ, whose table is empty, but it means adding a
1042    ///    3-decimal code to `CURRENCY_FORMS` would silently mis-split it.
1043    /// 3. **`self.negword` does not exist.** Python's
1044    ///    `minus_str = "%s " % self.negword.strip() if is_negative else ""` is
1045    ///    a conditional *expression*, so the attribute is only touched on the
1046    ///    negative branch. `__init__` is a bare `pass`, `setup()` never runs,
1047    ///    and there is no class-level fallback — so every negative value dies
1048    ///    with `AttributeError` while non-negatives sail through.
1049    fn to_currency(
1050        &self,
1051        val: &CurrencyValue,
1052        currency: &str,
1053        cents: bool,
1054        separator: Option<&str>,
1055        adjective: bool,
1056    ) -> Result<String> {
1057        // The trait hands us None when the caller omitted `separator=`;
1058        // resolve it to LIJ's own `separator=" e"` before the ported body.
1059        let separator = separator.unwrap_or(self.default_separator());
1060
1061        // `parse_currency_parts(val)` — bare call, module defaults. See (1).
1062        let (left, right, is_negative) = parse_currency_parts(val, true, false, 100);
1063
1064        // The forms lookup precedes the negword read, so an unknown code
1065        // raises NotImplementedError even for a negative value: the corpus has
1066        // `currency:KWD` / arg `-12.34` -> NotImplementedError, not
1067        // AttributeError.
1068        let forms = self.currency_forms.get(currency).ok_or_else(|| {
1069            N2WError::NotImplemented(format!(
1070                "Currency code \"{}\" not implemented for \"{}\"",
1071                currency,
1072                self.lang_name()
1073            ))
1074        })?;
1075        let cr2 = &forms.subunit;
1076        let mut cr1 = forms.unit.clone();
1077
1078        if adjective {
1079            if let Some((adj_sg, adj_pl)) = self.currency_adjectives.get(currency) {
1080                // Python *suffixes* the adjective and rebuilds cr1 as a fresh
1081                // 2-tuple: `("%s %s" % (cr1[0], adjs[0]), "%s %s" % (cr1[1],
1082                // adjs[1]))`. Note this is not `currency::prefix_currency`, and
1083                // it indexes 0 and 1 explicitly rather than mapping the tuple.
1084                // -> "dozze dòllai americhen e trentequattro citti".
1085                let sg = cr1.first().ok_or_else(tuple_index_error)?.clone();
1086                let pl = cr1.get(1).ok_or_else(tuple_index_error)?.clone();
1087                cr1 = vec![format!("{} {}", sg, adj_sg), format!("{} {}", pl, adj_pl)];
1088            }
1089        }
1090
1091        let gender = if CURRENCIES_FEM.contains(&currency) {
1092            "f"
1093        } else {
1094            "m"
1095        };
1096
1097        // See (3). Everything above this line runs first in Python too.
1098        if is_negative {
1099            return Err(N2WError::Attribute(
1100                "'Num2Word_LIJ' object has no attribute 'negword'".into(),
1101            ));
1102        }
1103
1104        // `right` came back with keep_precision=False, so it is a whole number
1105        // of cents in 0..=99 and carries scale 0.
1106        let right_int = right.as_bigint_and_exponent().0;
1107
1108        // Only the *unit* count is gendered; `self.to_cardinal(right)` takes
1109        // the default gender="m", so 2 GBP-cents is "doî pence", never "doe".
1110        let money_str = to_cardinal_gender(&left, gender)?;
1111        let cents_str = if cents {
1112            to_cardinal_gender(&right_int, "m")?
1113        } else {
1114            // Python hardcodes `"%02d" % right` here rather than calling
1115            // `_cents_terse`, so the width is 2 regardless of the divisor.
1116            format!("{:0>2}", right_int)
1117        };
1118
1119        // Python: "%s%s %s%s %s %s" % (minus_str, money_str, ...) — `minus_str`
1120        // is provably "" here, since the only branch that sets it raised above.
1121        Ok(format!(
1122            "{} {}{} {} {}",
1123            money_str,
1124            self.pluralize(&left, &cr1)?,
1125            separator,
1126            cents_str,
1127            self.pluralize(&right_int, cr2)?
1128        ))
1129    }
1130}