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(¤cy) {
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}