Skip to main content

num2words2_core/
lang_la.rs

1//! Latin (registry key `"la"`).
2//!
3//! The **number words** (cardinal / ordinal / ordinal_num / year, plus the
4//! float grammar) are a port of PR savoirfairelinux/num2words#657 (Piotr
5//! Balwierz's `add-latin` branch): properly declined cardinals across 3 genders
6//! × 6 cases for the declined forms (1, 2, 3, 200..900), Classical macrons
7//! (`ūnus`, `trēs`, strippable with `macrons=False`), true ordinals with a Roman
8//! `to_ordinal_num` (`4` → "IV"), and BC years ("ante Chrīstum"). See
9//! [`la_to_cardinal_pos`] / [`la_ordinal_int`]. `MAXVAL` is 10^18; larger values
10//! raise `OverflowError`.
11//!
12//! The **currency** path is deliberately NOT from PR #657 — that module ships
13//! no `CURRENCY_FORMS` and would raise NotImplementedError. It keeps the fork's
14//! own hand-rolled `to_currency` + [`LangLa::int_to_word`] (a basic, no-macron
15//! cardinal), so `to_currency` output is unchanged from the frozen corpus.
16//! `int_to_word`'s quirks (bugs 3–8 below) therefore still describe the
17//! currency surface, but no longer the plain cardinals.
18//!
19//! `setup()` only rebinds plain scalars (`negword`, `pointword`, `ones`,
20//! `tens`, `hundred`, `thousand`, `million`), so they are module consts here
21//! rather than struct fields — `LangLa` is a unit struct.
22//!
23//! Inherited from `Num2Word_Base` and **not** overridden by LA:
24//!   * `str_to_number(value) -> Decimal(value)`. LA stashes no flag in it, so
25//!     there is **no cross-call mutable-state handshake** of the `lang_ES`
26//!     `_pending_ordinal` kind. The Rust path is safe to dispatch to.
27//!   * `to_cheque` — base's, so it goes through `currency::default_to_cheque`
28//!     and the strict [`Lang::currency_forms`] lookup. This is the *only* LA
29//!     entry point that can raise on an unknown currency code.
30//!   * `_money_verbose` -> `self.to_cardinal(number)`, which resolves to LA's
31//!     override. The trait default does the same, so it is not re-implemented.
32//!   * `pluralize` — base's abstract `raise NotImplementedError`. **Unreachable
33//!     for LA**: `to_currency` open-codes its own two-form selection and
34//!     `to_cheque` takes `cr1[-1]` directly, so neither ever calls it. Left at
35//!     the trait default deliberately.
36//!   * `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}` on this
37//!     class (verified on the live class object, not the source), so the
38//!     `currency_precision` (-> 100) and `currency_adjective` (-> `None`) trait
39//!     defaults are already correct and are not overridden.
40//!
41//! Number words (PR #657): `to_cardinal`/`to_ordinal`/`to_year` take
42//! `gender=`/`case=`/`macrons=` kwargs; `to_ordinal_num` is a Roman numeral.
43//! `to_currency` is the fork's own, hand-rolled over [`LangLa::int_to_word`].
44//!
45//! # `int_to_word` quirks (currency path only)
46//!
47//! [`LangLa::int_to_word`] is the fork's basic cardinal, retained solely for
48//! `to_currency`. It is *not* Classical Latin, but it is what the frozen corpus
49//! records for the currency surface, so it is kept verbatim. The bugs below
50//! describe `int_to_word`; the PR #657 cardinals above do not share them.
51//!
52//! 3. **Values ≥ 10^9 are emitted as bare digits.** `_int_to_word`'s final
53//!    `else` is `return str(number)`, so `to_cardinal(10**9) == "1000000000"`
54//!    and `to_cardinal(10**21) == "1000000000000000000000"` — decimal
55//!    numerals, not words. The negword still gets prefixed by `to_cardinal`,
56//!    so `to_cardinal(-10**9) == "minus 1000000000"` (verified; no corpus row
57//!    covers it, but it falls out of the same code path). This fallback is
58//!    why Latin needs `BigInt` and can never be narrowed to a fixed-width int.
59//! 4. **`ones[0]` is `""`, and there is no `zero` attribute.** Zero only works
60//!    because of the falsy check `self.ones[0] if self.ones[0] else "zero"`,
61//!    which always takes the else branch. Modelled literally in [`ZERO`].
62//! 5. **Teens are formed by juxtaposition**: 11 → "decem unus", 19 → "decem
63//!    novem" (Latin proper: "undecim", "undeviginti"). Likewise 12345 →
64//!    "decem duo mille …".
65//! 6. **`unus` is prefixed unconditionally** to hundreds and thousands: 100 →
66//!    "unus centum" (not "centum"), 1000 → "unus mille" (not "mille"),
67//!    1000000 → "unus decies centena milia".
68//! 7. **`million = "decies centena milia"`** ("ten hundred thousands") is used
69//!    as the multiplicand for *every* millions count, so 2·10^6 →
70//!    "duo decies centena milia" — literally "two ten hundred thousands".
71//! 8. **`_int_to_word`'s `number < 0` branch is dead code.** `to_cardinal`
72//!    strips the sign before calling it, and `to_currency` passes `abs(val)`.
73//!    Reproduced below to mirror the source structure; it is unreachable from
74//!    every in-scope entry point.
75//! 9. `pointword` is the English `"point"` — visible only on the float path
76//!    ([`LangLa::to_cardinal_float`], now ported below). It is emitted raw,
77//!    not `title`d: LA's `to_cardinal` writes `self.pointword` literally, and
78//!    `is_title` is false anyway.
79//! 10. **An unknown currency code silently becomes EUR** in `to_currency`:
80//!     `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
81//!     So `to_currency(1, "GBP")` is "unus euro" and `to_currency(1, "JPY")` is
82//!     "unus euro" — nine distinct codes in the corpus all render as euros.
83//!     `to_cheque` does **not** share this: it is base's, uses `[currency]`, and
84//!     raises NotImplementedError for the very same codes. Both are in the
85//!     corpus, so the leniency cannot be hoisted into `currency_forms`.
86//! 11. **`CURRENCY_PRECISION` is `{}`, so every currency is 2-decimal.** LA's
87//!     `to_currency` never consults it at all: KWD/BHD get no 1000 divisor and
88//!     JPY no 1 divisor. Combined with bug 10, `currency:KWD 12.34` is
89//!     "decem duo euros triginta quattuor centesimae" — a 3-decimal currency
90//!     rendered as 2-decimal euros. `to_cheque` *does* read it, but only ever
91//!     finds the 100 default.
92//! 12. **Cents truncate rather than round**: `parts[1][:2]` slices the string,
93//!     so `1.999` -> "unus euro nonaginta novem centesimae" (99, not 100 and a
94//!     carry). Verified against the interpreter.
95//! 13. **`adjective` is accepted and ignored.** LA declares the parameter and
96//!     never reads it; `CURRENCY_ADJECTIVES` is `{}` anyway.
97//! 14. **`cents=False` omits the subunit entirely** rather than rendering it as
98//!     terse digits the way `Num2Word_Base.to_currency` does — the guard is
99//!     `if cents and right`, so there is no `_cents_terse` call. LA therefore
100//!     answers `to_currency(12.34, cents=False)` with "decem duo euros", where
101//!     base would say "decem duo euros,34".
102//!
103//! # Error variants
104//!
105//! The four integer modes have **no** reachable raise or crash site: no card
106//! table means no `OverflowError`, no dict lookups means no `KeyError`, every
107//! list index is provably bounded (see [`digit`]), and no `int()` ever sees a
108//! stray `"-"`. Every integer-mode corpus row is `ok: true`.
109//!
110//! The currency surface adds exactly two:
111//!   * `NotImplemented` — `to_cheque` on any code outside {EUR, USD}, raised by
112//!     `currency::default_to_cheque` off a `currency_forms` miss. Seven of the
113//!     nine `cheque:*` corpus rows are this. `to_currency` never raises it
114//!     (bug 10).
115//!   * `Value` — `to_currency` on a value whose `str()` uses exponent notation
116//!     (`int('1e+21')` -> ValueError). Not covered by the corpus; see
117//!     `split_value` and "Known divergence" below.
118//!
119//! The remaining `ok: false` rows for `"la"` are `fraction` and `w2n_cardinal`,
120//! both outside this port's scope.
121//!
122//! # Known divergence (one, and it is not fixable here)
123//!
124//! A float whose `repr` switches to *negative* exponent notation — i.e. a
125//! non-zero `abs(v) < 1e-4`, such as `1e-05` — should raise ValueError
126//! (`int('1e-05')`), but this port renders it "zero euros".
127//!
128//! It cannot be fixed inside this file, because the distinction is erased
129//! before the value arrives. `__init__.py` passes `str(number)` plus a single
130//! `is_int` flag; for non-ints it does **not** say whether the caller had a
131//! `float` or a `decimal.Decimal`. Yet Python disagrees with itself on exactly
132//! that basis for the same numeric value (verified):
133//!
134//! ```text
135//! to_currency(1e-05)             -> ValueError   # str() is '1e-05'
136//! to_currency(Decimal('0.00001')) -> 'zero euros' # str() is '0.00001'
137//! ```
138//!
139//! Both reach this crate as the identical `BigDecimal { digits: 1, scale: 5 }`,
140//! so no rule over `(digits, scale)` can separate them; "zero euros" is right
141//! for one input and wrong for the other. Telling them apart needs the original
142//! string (or `type(number)`) plumbed through the shim, which is off-limits and
143//! shared by ~150 languages. The *positive*-exponent half of the same problem
144//! (`1e+21`, `1.5e+20`) **is** reproduced exactly, because a negative
145//! `BigDecimal` scale is unambiguous — a plain decimal string can never produce
146//! one, and both `float` and `Decimal` stringify to exponent form there.
147
148use crate::base::{Kwargs, Lang, N2WError, Result};
149use crate::currency::{CurrencyForms, CurrencyValue};
150use crate::floatpath::{default_to_cardinal_float, FloatValue};
151use num_bigint::BigInt;
152use num_integer::Integer;
153use num_traits::{Signed, ToPrimitive, Zero};
154use std::collections::HashMap;
155
156/// `self.negword`, verbatim — note the trailing space is part of the literal.
157///
158/// `to_cardinal` uses `ret = self.negword` **raw**, not the
159/// `"%s " % self.negword.strip()` dance that `Num2Word_Base.parse_minus` and
160/// `Num2Word_Base.to_cardinal` perform. Same result here only because the
161/// literal already ends in exactly one space.
162const NEGWORD: &str = "minus ";
163
164/// The word `_int_to_word` reaches for zero. Python writes
165/// `self.ones[0] if self.ones[0] else "zero"`; `ones[0]` is `""` (falsy), so
166/// the else branch always wins. See bug 4.
167const ZERO: &str = "zero";
168
169/// `self.ones`. Index 0 is `""` and is never emitted — see [`ZERO`].
170const ONES: [&str; 10] = [
171    "", "unus", "duo", "tres", "quattuor", "quinque", "sex", "septem", "octo", "novem",
172];
173
174/// `self.tens`. Index 0 is `""`; unreachable because the `< 100` branch is
175/// only entered when `number >= 10`, so `tens_val >= 1`.
176const TENS: [&str; 10] = [
177    "",
178    "decem",
179    "viginti",
180    "triginta",
181    "quadraginta",
182    "quinquaginta",
183    "sexaginta",
184    "septuaginta",
185    "octoginta",
186    "nonaginta",
187];
188
189const HUNDRED: &str = "centum";
190const THOUSAND: &str = "mille";
191/// `self.million`. See bug 7 — this is "ten hundred thousands", used as the
192/// multiplicand for every millions count.
193const MILLION: &str = "decies centena milia";
194
195/// The separator the pyo3 binding hands us when the Python caller omitted one.
196///
197/// `Num2Word_LA.to_currency` declares `separator=" "` in its own signature, but
198/// the `Lang` trait takes the separator as a plain argument, and both
199/// `num2words2/__init__.py`'s Rust fast path and `bench/diff_test.py` substitute
200/// `kwargs.get("separator", ",")` — **`Num2Word_Base`'s** default, not LA's —
201/// before the value ever reaches Rust. By the time we see it, "caller omitted
202/// separator" and "caller asked for a comma" are the same string and the
203/// information is gone.
204///
205/// Every currency row in the frozen corpus is generated by
206/// `num2words(v, lang="la", to="currency", currency=c)` with no `separator=`, so
207/// Python renders them with LA's " " ("decem duo euros triginta quattuor
208/// centesimae"), while `diff_test.py` feeds this core a ",". Mapping "," back to
209/// " " restores LA's default and reproduces the corpus. The one input it gets
210/// wrong is an *explicit* `separator=","`, which Python renders as
211/// "decem duo euros,triginta quattuor centesimae" (no space — LA concatenates
212/// the separator raw, verified against the interpreter) and which we render as
213/// " ". Fixing that properly means teaching the shim to pass the converter's own
214/// default; that is out of scope here (`__init__.py` is off-limits and shared by
215/// ~150 languages), so the narrower divergence is the deliberate choice.
216/// `lang_bs.rs`, `lang_ca.rs` and `lang_es.rs` hit the identical trap and
217/// resolve it the same way — 63 Python modules declare `separator=" "`.
218const SEPARATOR_UNSET: &str = ",";
219
220/// `Num2Word_LA.CURRENCY_FORMS`'s de-facto fallback key.
221///
222/// `to_currency` does
223/// `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
224/// `CURRENCY_FORMS` is a dict literal with EUR written first, and Python dicts
225/// preserve insertion order, so `values()[0]` is **always** EUR's pair. That is
226/// what every unrecognised code silently becomes — see bug 10.
227const FALLBACK_CURRENCY: &str = "EUR";
228
229/// Narrow a provably-small `BigInt` to a table index.
230///
231/// Every call site is guarded by a branch that bounds the value to `0..=9`
232/// (`number < 10`, or a `// 10` / `// 100` of a value below 100 / 1000), so
233/// the conversion cannot fail. Panicking here would mean the guard above it
234/// was rewritten incorrectly.
235fn digit(n: &BigInt) -> usize {
236    n.to_usize()
237        .filter(|d| *d < 10)
238        .expect("branch guard bounds this value to 0..=9")
239}
240
241/// Python's `int(s)` on the integer part of a repr string, as reached by LA's
242/// float `to_cardinal`. The float path feeds only the digits before the ".",
243/// which `format!` guarantees are `[0-9]+`, so this never fails in practice;
244/// the error arm mirrors the `ValueError` `int()` raises on a bad literal.
245fn parse_int(s: &str) -> Result<BigInt> {
246    s.parse::<BigInt>().map_err(|_| {
247        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
248    })
249}
250
251// ─────────────────────────────────────────────────────────────────────────────
252// PR savoirfairelinux/num2words#657 — declined Latin cardinals/ordinals.
253//
254// Data and algorithm are a faithful port of Piotr Balwierz's `lang_LA.py`
255// (branch `add-latin`). It replaces the fork's basic `_int_to_word`-based
256// number words (which stay live *only* for `to_currency`, unchanged). Cardinals
257// decline across 3 genders × 6 cases for the declined forms (1, 2, 3, 200..900);
258// tables carry Classical macrons, strippable with `macrons=False`.
259//
260// gender index: m=0, f=1, n=2 ; case index: nom=0 gen=1 dat=2 acc=3 abl=4 voc=5
261// ─────────────────────────────────────────────────────────────────────────────
262
263const CASES: [&str; 6] = ["nom", "gen", "dat", "acc", "abl", "voc"];
264
265fn gender_idx(g: &str) -> Option<usize> {
266    match g {
267        "m" => Some(0),
268        "f" => Some(1),
269        "n" => Some(2),
270        _ => None,
271    }
272}
273
274fn case_idx(c: &str) -> Option<usize> {
275    CASES.iter().position(|&x| x == c)
276}
277
278// _DECL_1 / _DECL_2 / _DECL_3 — [gender][case].
279const DECL_1: [[&str; 6]; 3] = [
280    ["ūnus", "ūnīus", "ūnī", "ūnum", "ūnō", "ūne"],
281    ["ūna", "ūnīus", "ūnī", "ūnam", "ūnā", "ūna"],
282    ["ūnum", "ūnīus", "ūnī", "ūnum", "ūnō", "ūnum"],
283];
284const DECL_2: [[&str; 6]; 3] = [
285    ["duo", "duōrum", "duōbus", "duōs", "duōbus", "duo"],
286    ["duae", "duārum", "duābus", "duās", "duābus", "duae"],
287    ["duo", "duōrum", "duōbus", "duo", "duōbus", "duo"],
288];
289const DECL_3: [[&str; 6]; 3] = [
290    ["trēs", "trium", "tribus", "trēs", "tribus", "trēs"],
291    ["trēs", "trium", "tribus", "trēs", "tribus", "trēs"],
292    ["tria", "trium", "tribus", "tria", "tribus", "tria"],
293];
294
295// _hundred_paradigm: stem + these endings, [gender][case].
296const HUND_ENDING: [[&str; 6]; 3] = [
297    ["ī", "ōrum", "īs", "ōs", "īs", "ī"],
298    ["ae", "ārum", "īs", "ās", "īs", "ae"],
299    ["a", "ōrum", "īs", "a", "īs", "a"],
300];
301// _HUNDREDS stems, index by the hundreds digit 2..=9 (1 → "centum" is special).
302const HUND_STEM: [&str; 10] = [
303    "", "", "ducent", "trecent", "quadringent", "quīngent", "sescent",
304    "septingent", "octingent", "nōngent",
305];
306
307// _UNDER_20_INVARIANT, index 4..=19 (0..=3 handled elsewhere).
308const UNDER_20: [&str; 20] = [
309    "", "", "", "", "quattuor", "quīnque", "sex", "septem", "octō", "novem",
310    "decem", "ūndecim", "duodecim", "tredecim", "quattuordecim", "quīndecim",
311    "sēdecim", "septendecim", "duodēvīgintī", "ūndēvīgintī",
312];
313
314// _TENS_INVARIANT, index 2..=9.
315const TENS_INV: [&str; 10] = [
316    "", "", "vīgintī", "trīgintā", "quadrāgintā", "quīnquāgintā", "sexāgintā",
317    "septuāgintā", "octōgintā", "nōnāgintā",
318];
319
320// _MILIA_DECL (neut. pl.), index by case.
321const MILIA: [&str; 6] = ["mīlia", "mīlium", "mīlibus", "mīlia", "mīlibus", "mīlia"];
322
323// _zero: "nullus/nulla/nullum" declension (no macrons), [gender][case].
324const ZERO_DECL: [[&str; 6]; 3] = [
325    ["nullus", "nullius", "nulli", "nullum", "nullo", "nulle"],
326    ["nulla", "nullius", "nulli", "nullam", "nulla", "nulla"],
327    ["nullum", "nullius", "nulli", "nullum", "nullo", "nullum"],
328];
329
330// _decline_us_um_adj endings, [gender][case].
331const ADJ_ENDING: [[&str; 6]; 3] = [
332    ["us", "ī", "ō", "um", "ō", "e"],
333    ["a", "ae", "ae", "am", "ā", "a"],
334    ["um", "ī", "ō", "um", "ō", "um"],
335];
336
337// _HIGH_UNITS in descending power. Only the nominative (sg/pl) is ever used;
338// every unit is masculine, and its modifier is always masc.nom.
339const HIGH_UNITS: [(u64, &str, &str); 4] = [
340    (1_000_000_000_000_000, "billiardus", "billiardi"),
341    (1_000_000_000_000, "billio", "billiones"),
342    (1_000_000_000, "miliardus", "miliardi"),
343    (1_000_000, "milio", "miliones"),
344];
345
346const LA_MAXVAL_WORDS_EXP: u32 = 18; // 10^18
347
348/// Strip Classical macrons to plain ASCII when `macrons` is false.
349fn apply_macrons(s: &str, macrons: bool) -> String {
350    if macrons {
351        return s.to_string();
352    }
353    s.chars()
354        .map(|c| match c {
355            'ā' => 'a',
356            'ē' => 'e',
357            'ī' => 'i',
358            'ō' => 'o',
359            'ū' => 'u',
360            'Ā' => 'A',
361            'Ē' => 'E',
362            'Ī' => 'I',
363            'Ō' => 'O',
364            'Ū' => 'U',
365            'ȳ' => 'y',
366            'Ȳ' => 'Y',
367            other => other,
368        })
369        .collect()
370}
371
372/// `_form(n, gender, case)` — declined cardinal for 1, 2, 3 or 200..=900.
373fn la_form(n: u32, g: usize, c: usize) -> &'static str {
374    match n {
375        1 => DECL_1[g][c],
376        2 => DECL_2[g][c],
377        3 => DECL_3[g][c],
378        _ => {
379            // n is h*100 with 2 <= h <= 9. Paradigm = stem + ending; the two
380            // string pieces are concatenated at call sites that need them.
381            // Callers of la_form for hundreds use la_hundred instead.
382            unreachable!("la_form only handles 1,2,3; hundreds via la_hundred")
383        }
384    }
385}
386
387/// A declined hundreds cardinal (200..=900): stem + gender/case ending.
388fn la_hundred(h: usize, g: usize, c: usize) -> String {
389    format!("{}{}", HUND_STEM[h], HUND_ENDING[g][c])
390}
391
392/// `_low(n, gender, case)` — cardinal 0..=19 (0 → empty).
393fn la_low(n: u32, g: usize, c: usize) -> String {
394    match n {
395        0 => String::new(),
396        1 | 2 | 3 => la_form(n, g, c).to_string(),
397        _ => UNDER_20[n as usize].to_string(),
398    }
399}
400
401/// `_under_1000(n, gender, case)` — 0..=999. Form-sensitive for 1,2,3,200..900.
402fn la_under_1000(n: u32, g: usize, c: usize) -> String {
403    let h = n / 100;
404    let rest = n % 100;
405    let t = rest / 10;
406    let u = rest % 10;
407    let mut parts: Vec<String> = Vec::new();
408    if h != 0 {
409        if h == 1 {
410            parts.push("centum".to_string());
411        } else {
412            parts.push(la_hundred(h as usize, g, c));
413        }
414    }
415    if rest == 0 {
416        // nothing
417    } else if rest <= 19 {
418        parts.push(la_low(rest, g, c));
419    } else {
420        if t != 0 {
421            parts.push(TENS_INV[t as usize].to_string());
422        }
423        if u != 0 {
424            parts.push(la_low(u, g, c));
425        }
426    }
427    parts.join(" ")
428}
429
430/// `_zero(gender, case)`.
431fn la_zero(g: usize, c: usize) -> &'static str {
432    ZERO_DECL[g][c]
433}
434
435/// `_to_cardinal_pos(n, gender, case)` — compose a non-negative < 10^18.
436fn la_to_cardinal_pos(n: &BigInt, g: usize, c: usize) -> String {
437    if n.is_zero() {
438        return la_zero(g, c).to_string();
439    }
440    if n == &BigInt::from(1) {
441        return la_form(1, g, c).to_string();
442    }
443
444    let mut parts: Vec<String> = Vec::new();
445    let mut rest = n.clone();
446
447    for (power, sg, pl) in HIGH_UNITS.iter() {
448        let p = BigInt::from(*power);
449        let (count, r) = rest.div_rem(&p);
450        rest = r;
451        if count.is_zero() {
452            continue;
453        }
454        if count == BigInt::from(1) {
455            // "ūnus" agrees with the singular masc. noun.
456            parts.push(format!("{} {}", DECL_1[0][0], sg));
457        } else {
458            let cnt = count.to_u32().expect("count < 1000 by construction");
459            parts.push(format!("{} {}", la_under_1000(cnt, 0, 0), pl));
460        }
461    }
462
463    // Thousands: 1000 → "mīlle"; 2000+ → "<neut.pl. cardinal> mīlia[case]".
464    let thousand = BigInt::from(1000);
465    let (thousands, r) = rest.div_rem(&thousand);
466    rest = r;
467    if thousands == BigInt::from(1) {
468        parts.push("mīlle".to_string());
469    } else if thousands >= BigInt::from(2) {
470        let th = thousands.to_u32().expect("thousands < 1000 by construction");
471        parts.push(format!("{} {}", la_under_1000(th, 2, 0), MILIA[c]));
472    }
473
474    if rest > BigInt::zero() {
475        let rr = rest.to_u32().expect("remainder < 1000");
476        parts.push(la_under_1000(rr, g, c));
477    }
478
479    parts.join(" ")
480}
481
482/// `_ORDINALS_LOW` lookup (masc.nom.sg. citation stem).
483fn la_ordinal_low(n: u32) -> Option<&'static str> {
484    let s = match n {
485        1 => "prīmus",
486        2 => "secundus",
487        3 => "tertius",
488        4 => "quārtus",
489        5 => "quīntus",
490        6 => "sextus",
491        7 => "septimus",
492        8 => "octāvus",
493        9 => "nōnus",
494        10 => "decimus",
495        11 => "ūndecimus",
496        12 => "duodecimus",
497        13 => "tertius decimus",
498        14 => "quārtus decimus",
499        15 => "quīntus decimus",
500        16 => "sextus decimus",
501        17 => "septimus decimus",
502        18 => "duodēvīcēsimus",
503        19 => "ūndēvīcēsimus",
504        20 => "vīcēsimus",
505        30 => "trīcēsimus",
506        40 => "quadrāgēsimus",
507        50 => "quīnquāgēsimus",
508        60 => "sexāgēsimus",
509        70 => "septuāgēsimus",
510        80 => "octōgēsimus",
511        90 => "nōnāgēsimus",
512        100 => "centēsimus",
513        200 => "ducentēsimus",
514        300 => "trecentēsimus",
515        400 => "quadringentēsimus",
516        500 => "quīngentēsimus",
517        600 => "sescentēsimus",
518        700 => "septingentēsimus",
519        800 => "octingentēsimus",
520        900 => "nōngentēsimus",
521        1000 => "mīllēsimus",
522        1_000_000 => "deciēs centiēs mīllēsimus",
523        _ => return None,
524    };
525    Some(s)
526}
527
528/// `_decline_us_um_adj(stem, gender, case)` — 1st/2nd-decl. adjective from its
529/// masc.nom.sg. citation form to any other slot. Compound stems (space) get
530/// each word inflected.
531fn la_decline_adj(stem: &str, g: usize, c: usize) -> String {
532    if g == 0 && c == 0 {
533        return stem.to_string();
534    }
535    if let Some((head, tail)) = stem.split_once(' ') {
536        return format!("{} {}", la_decline_adj(head, g, c), la_decline_adj(tail, g, c));
537    }
538    let base = if stem.ends_with("us") || stem.ends_with("um") {
539        &stem[..stem.len() - 2]
540    } else {
541        return stem.to_string();
542    };
543    format!("{}{}", base, ADJ_ENDING[g][c])
544}
545
546const ROMAN_TABLE: [(u32, &str); 13] = [
547    (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
548    (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
549    (10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
550    (1, "I"),
551];
552
553/// `_to_roman(n)`. Falls back to plain digits for n <= 0 or n >= 4000.
554fn la_to_roman(n: &BigInt) -> String {
555    let mut m = match n.to_u32() {
556        Some(v) if (1..4000).contains(&v) => v,
557        _ => return n.to_string(),
558    };
559    let mut out = String::new();
560    for (v, sym) in ROMAN_TABLE.iter() {
561        while m >= *v {
562            out.push_str(sym);
563            m -= *v;
564        }
565    }
566    out
567}
568
569fn la_normalize_grammar(gender: &str, case: &str) -> Result<(usize, usize)> {
570    let g = gender_idx(gender).ok_or_else(|| {
571        N2WError::Value(format!("gender must be 'm', 'f' or 'n'; got '{}'", gender))
572    })?;
573    let c = case_idx(case).ok_or_else(|| {
574        N2WError::Value(format!(
575            "case must be one of nom/gen/dat/acc/abl/voc; got '{}'",
576            case
577        ))
578    })?;
579    Ok((g, c))
580}
581
582fn la_maxval_words() -> BigInt {
583    BigInt::from(10).pow(LA_MAXVAL_WORDS_EXP)
584}
585
586/// Read `gender`/`case`/`macrons` from a kwargs bag (PR #657 signature). An
587/// unexpected key mirrors Python's TypeError for a bad keyword argument.
588fn la_grammar_kw(kw: &Kwargs) -> Result<(&str, &str, bool)> {
589    if !kw.only(&["gender", "case", "macrons"]) {
590        return Err(N2WError::Type(
591            "got an unexpected keyword argument".into(),
592        ));
593    }
594    let g = kw.str("gender").unwrap_or("m");
595    let c = kw.str("case").unwrap_or("nom");
596    let m = kw.bool("macrons").unwrap_or(true);
597    Ok((g, c, m))
598}
599
600/// `to_cardinal(value, gender, case, macrons)` for an integer value.
601fn la_cardinal_int(value: &BigInt, gender: &str, case: &str, macrons: bool) -> Result<String> {
602    let (g, c) = la_normalize_grammar(gender, case)?;
603    if value.is_negative() {
604        let inner = la_cardinal_int(&(-value), gender, case, true)?;
605        return Ok(apply_macrons(
606            &format!("{} {}", NEGWORD.trim(), inner),
607            macrons,
608        ));
609    }
610    let maxw = la_maxval_words();
611    if value >= &maxw {
612        return Err(N2WError::Overflow(format!(
613            "abs({}) maius esse nōn dēbet quam {}.",
614            value, maxw
615        )));
616    }
617    Ok(apply_macrons(&la_to_cardinal_pos(value, g, c), macrons))
618}
619
620/// `to_ordinal(value, gender, case, macrons)` for an integer value.
621fn la_ordinal_int(value: &BigInt, gender: &str, case: &str, macrons: bool) -> Result<String> {
622    // verify_ordinal (integer path): a negative value raises TypeError.
623    if value.is_negative() {
624        return Err(N2WError::Type(format!(
625            "Numerus negātīvus {} ōrdinālis fierī nōn potest.",
626            value
627        )));
628    }
629    if let Some(v) = value.to_u32() {
630        if let Some(stem) = la_ordinal_low(v) {
631            let (g, c) = la_normalize_grammar(gender, case)?;
632            return Ok(apply_macrons(&la_decline_adj(stem, g, c), macrons));
633        }
634    }
635    // Compound / out-of-table ordinals fall back to the cardinal form.
636    la_cardinal_int(value, gender, case, macrons)
637}
638
639/// `to_ordinal_num(value)` — Roman numeral. verify_ordinal first.
640fn la_ordinal_num_int(value: &BigInt) -> Result<String> {
641    if value.is_negative() {
642        return Err(N2WError::Type(format!(
643            "Numerus negātīvus {} ōrdinālis fierī nōn potest.",
644            value
645        )));
646    }
647    Ok(la_to_roman(value))
648}
649
650/// `to_year(value, macrons)`. Negative years take an "ante Chrīstum" (BC) prefix.
651fn la_year_int(value: &BigInt, macrons: bool) -> Result<String> {
652    if value.is_negative() {
653        let body = format!(
654            "ante Chrīstum {}",
655            la_cardinal_int(&(-value), "m", "nom", true)?
656        );
657        Ok(apply_macrons(&body, macrons))
658    } else {
659        la_cardinal_int(value, "m", "nom", macrons)
660    }
661}
662
663pub struct LangLa {
664    /// `Num2Word_LA.CURRENCY_FORMS`, built once here rather than per call.
665    ///
666    /// LA declares its own class-level dict, so it is **untouched** by
667    /// `Num2Word_EN.__init__`'s in-place mutation of the `Num2Word_EUR` dict:
668    /// exactly two codes, no AUD/JPY/KWD/… leakage, and EUR keeps LA's own
669    /// `("euro", "euros")` rather than English's. Verified against the live
670    /// interpreter, not the source literal.
671    ///
672    /// Both entries carry exactly **two** forms, matching Python's 2-tuples;
673    /// `to_currency` only ever indexes `[0]` and `[1]`.
674    currency_forms: HashMap<&'static str, CurrencyForms>,
675    /// `list(self.CURRENCY_FORMS.values())[0]` — EUR's pair, kept alongside the
676    /// map purely so the `.get(currency, <default>)` fallback in `to_currency`
677    /// is a borrow with no panic path. Python evaluates that default eagerly on
678    /// every call; here it cannot fail.
679    eur: CurrencyForms,
680}
681
682impl Default for LangLa {
683    fn default() -> Self {
684        Self::new()
685    }
686}
687
688impl LangLa {
689    pub fn new() -> Self {
690        // CURRENCY_FORMS = {
691        //     "EUR": (("euro", "euros"), ("centesima", "centesimae")),
692        //     "USD": (("dollar", "dollars"), ("cent", "cents")),
693        // }
694        let eur = CurrencyForms::new(&["euro", "euros"], &["centesima", "centesimae"]);
695        let currency_forms: HashMap<&'static str, CurrencyForms> = [
696            (FALLBACK_CURRENCY, eur.clone()),
697            (
698                "USD",
699                CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
700            ),
701        ]
702        .into_iter()
703        .collect();
704
705        LangLa { currency_forms, eur }
706    }
707
708    /// Port of `to_currency`'s hand-rolled value decomposition.
709    ///
710    /// ```python
711    /// is_negative = False
712    /// if val < 0:
713    ///     is_negative = True
714    ///     val = abs(val)
715    /// parts = str(val).split(".")
716    /// left = int(parts[0]) if parts[0] else 0
717    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
718    /// ```
719    ///
720    /// Returns `(left, right, is_negative)` with `right` in `0..=99`.
721    ///
722    /// This is *not* `parse_currency_parts` — LA never calls it, and never
723    /// consults `CURRENCY_PRECISION` either (it is `{}` on this class anyway, so
724    /// every code is implicitly 2-decimal: KWD/BHD get no 1000 divisor and JPY
725    /// no 1 divisor — see bug 11).
726    ///
727    /// The `Int`/`Decimal` split does **no** work here beyond "an int's `str()`
728    /// has no dot": LA skips the cents segment on `if cents and right`, not on
729    /// `isinstance(val, int)`. That is why `1.0` prints "unus euro" with no
730    /// cents even though it is a float — `str(1.0)` is "1.0", `parts[1]` is the
731    /// truthy `"0"`, and `right` computes to a falsy 0. The two variants still
732    /// must not be collapsed: `Int` must never grow a fraction.
733    ///
734    /// `parts[0]` is never empty for either variant (`str` always emits a
735    /// leading "0"), so Python's `if parts[0] else 0` guard is unreachable.
736    fn split_value(val: &CurrencyValue) -> Result<(BigInt, u32, bool)> {
737        match val {
738            // str(int) has no ".", so parts == [digits] and right stays 0.
739            CurrencyValue::Int(v) => Ok((v.abs(), 0, v.is_negative())),
740            CurrencyValue::Decimal { value: d, .. } => {
741                let is_negative = d.is_negative();
742                // `value == digits * 10^-scale`, with `digits`/`scale` taken
743                // verbatim from the `str(value)` the shim parsed — so
744                // reconstructing the plain-notation string from them reproduces
745                // Python's `str(abs(val))` exactly. `abs` before `str` is just
746                // the sign of `digits`. Note this deliberately does **not** use
747                // `BigDecimal`'s `Display`, which re-normalises (it renders
748                // `1e-05` as "0.00001", which `str(1e-05)` does not).
749                let (digits, scale) = d.as_bigint_and_exponent();
750                let digits = digits.abs();
751
752                if scale < 0 {
753                    // A negative scale can only come from exponent notation in
754                    // the source string, and `str()` emits that for both floats
755                    // (`str(1e21) == '1e+21'`) and Decimals
756                    // (`str(Decimal('1E+2')) == '1E+2'`). Either way Python then
757                    // does `int('1e+21')` and raises ValueError — this is a
758                    // genuine reachable crash, not a deliberate raise. Verified:
759                    // `to_currency(1e21)` -> ValueError. The message is
760                    // best-effort (only the exception *type* is compared, and
761                    // reproducing `repr(float)` is explicitly out of scope per
762                    // `currency.rs`'s header).
763                    return Err(N2WError::Value(format!(
764                        "invalid literal for int() with base 10: '{}'",
765                        d.abs()
766                    )));
767                }
768
769                if scale == 0 {
770                    // No "." in the string: the value is integral as written.
771                    return Ok((digits, 0, is_negative));
772                }
773
774                let pow = BigInt::from(10u32).pow(scale as u32);
775                let (left, frac) = digits.div_rem(&pow);
776                // `parts[1]` is `frac` zero-padded to `scale` digits;
777                // `int(parts[1][:2].ljust(2, "0"))` is then exactly "the first
778                // two fractional digits as a 2-digit number", i.e. floor
779                // division that keeps leading zeros and pads short fractions on
780                // the right. 0.5 -> frac 5, scale 1 -> 5*100/10 = 50.
781                // 1.999 -> frac 999, scale 3 -> 999*100/1000 = 99 (truncation,
782                // not rounding — bug 12). 0.01 -> frac 1, scale 2 -> 1.
783                let right = (frac * BigInt::from(100u32) / &pow).to_u32().unwrap_or(0);
784                Ok((left, right, is_negative))
785            }
786        }
787    }
788
789    /// Python's `_int_to_word`.
790    ///
791    /// The cascade is a straight transcription. Python's `//` and `%` are
792    /// floor division, but every operand that reaches them here is strictly
793    /// positive (the `number < 0` arm intercepts the rest), so
794    /// `div_mod_floor` and truncating division agree — the usual
795    /// negative-floor-division trap does not bite.
796    fn int_to_word(&self, number: &BigInt) -> String {
797        if number.is_zero() {
798            // `self.ones[0] if self.ones[0] else "zero"` — always the else.
799            return ZERO.to_string();
800        }
801
802        if number.is_negative() {
803            // Dead code in Python too. See bug 8.
804            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
805        }
806
807        if *number < BigInt::from(10) {
808            return ONES[digit(number)].to_string();
809        }
810
811        if *number < BigInt::from(100) {
812            let (tens_val, ones_val) = number.div_mod_floor(&BigInt::from(10));
813            if ones_val.is_zero() {
814                return TENS[digit(&tens_val)].to_string();
815            }
816            return format!("{} {}", TENS[digit(&tens_val)], ONES[digit(&ones_val)]);
817        }
818
819        if *number < BigInt::from(1000) {
820            let (hundreds_val, remainder) = number.div_mod_floor(&BigInt::from(100));
821            // `self.ones[hundreds_val] + " " + self.hundred` — unconditional,
822            // hence "unus centum" for 100. See bug 6.
823            let mut result = format!("{} {}", ONES[digit(&hundreds_val)], HUNDRED);
824            if !remainder.is_zero() {
825                result.push(' ');
826                result.push_str(&self.int_to_word(&remainder));
827            }
828            return result;
829        }
830
831        if *number < BigInt::from(1_000_000) {
832            let (thousands_val, remainder) = number.div_mod_floor(&BigInt::from(1000));
833            let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
834            if !remainder.is_zero() {
835                result.push(' ');
836                result.push_str(&self.int_to_word(&remainder));
837            }
838            return result;
839        }
840
841        if *number < BigInt::from(1_000_000_000) {
842            let (millions_val, remainder) = number.div_mod_floor(&BigInt::from(1_000_000));
843            let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
844            if !remainder.is_zero() {
845                result.push(' ');
846                result.push_str(&self.int_to_word(&remainder));
847            }
848            return result;
849        }
850
851        // `return str(number)  # Fallback for very large numbers`. See bug 3.
852        number.to_string()
853    }
854}
855
856impl Lang for LangLa {
857
858    fn cardinal_float_entry(
859        &self,
860        value: &crate::floatpath::FloatValue,
861        precision_override: Option<u32>,
862    ) -> crate::base::Result<String> {
863        // PR #657: `if int(value) != value` chooses the float grammar; a whole
864        // value collapses to the integer cardinal (Decimal("2.0") -> "duo").
865        if let Some(i) = value.as_whole_int() {
866            return self.to_cardinal(&i);
867        }
868        // Non-integer: the base decimal grammar (pre "virgula" digit-by-digit),
869        // driven by this language's macron-bearing `to_cardinal`.
870        default_to_cardinal_float(self, value, precision_override)
871    }
872
873    /// PR #657 `to_cardinal(float, macrons=)`. Gender/case do not affect the
874    /// float grammar (the pre/post parts are masc.nom); only `macrons` matters.
875    fn to_cardinal_float_kw(
876        &self,
877        value: &FloatValue,
878        precision_override: Option<u32>,
879        kw: &Kwargs,
880    ) -> Result<String> {
881        if !kw.only(&["gender", "case", "macrons"]) {
882            return Err(N2WError::Type(
883                "got an unexpected keyword argument".into(),
884            ));
885        }
886        let macrons = kw.bool("macrons").unwrap_or(true);
887        let s = self.cardinal_float_entry(value, precision_override)?;
888        Ok(apply_macrons(&s, macrons))
889    }
890
891    /// PR #657 `to_ordinal(float)` — `verify_ordinal` rejects fractional
892    /// (TypeError) and negative (TypeError) values; a whole value ordinalises.
893    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
894        match value.as_whole_int() {
895            Some(i) if !i.is_negative() => self.to_ordinal(&i),
896            Some(_) => Err(N2WError::Type(
897                "Numerus negātīvus ōrdinālis fierī nōn potest.".into(),
898            )),
899            None => Err(N2WError::Type(
900                "Numerus frāctus ōrdinālis fierī nōn potest.".into(),
901            )),
902        }
903    }
904
905    /// PR #657 `to_ordinal_num(float)` — Roman numeral for a whole value;
906    /// fractional / negative raise TypeError via `verify_ordinal`.
907    fn ordinal_num_float_entry(&self, value: &FloatValue, _repr_str: &str) -> Result<String> {
908        match value.as_whole_int() {
909            Some(i) if !i.is_negative() => Ok(la_to_roman(&i)),
910            Some(_) => Err(N2WError::Type(
911                "Numerus negātīvus ōrdinālis fierī nōn potest.".into(),
912            )),
913            None => Err(N2WError::Type(
914                "Numerus frāctus ōrdinālis fierī nōn potest.".into(),
915            )),
916        }
917    }
918
919    /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
920    /// Infinity sentinel becomes the ValueError this language's own
921    /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
922    /// fails); the shared dispatcher would otherwise report Base's
923    /// OverflowError. NaN keeps the base sentinel: the dispatcher's
924    /// ValueError for it already matches `int("NaN")`.
925    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
926        match crate::strnum::python_decimal_parse(s)? {
927            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
928                "invalid literal for int() with base 10: 'Infinity'".into(),
929            )),
930            p => Ok(p),
931        }
932    }
933
934    /// This language's own `to_currency(currency=...)` default,
935    /// read from the live Python signature. Only 44 of 156 use EUR.
936    fn default_currency(&self) -> &str {
937        "EUR"
938    }
939
940    /// This language's own `to_currency(separator=...)` default,
941    /// read from the live Python signature. Base's is ",", but only
942    /// 36 of 149 languages actually use it — most default to " " or a
943    /// conjunction, so inheriting Base's comma silently corrupts them.
944    fn default_separator(&self) -> &str {
945        " "
946    }
947
948    fn negword(&self) -> &str {
949        NEGWORD
950    }
951
952    /// PR #657 `pointword = "virgula"` (comma / decimal point).
953    fn pointword(&self) -> &str {
954        "virgula"
955    }
956
957    /// PR #657 `MAXVAL = 10^18`; drives the sentence `maxval("la")` and the
958    /// overflow guard in `la_cardinal_int`.
959    fn maxval(&self) -> &BigInt {
960        static MV: std::sync::OnceLock<BigInt> = std::sync::OnceLock::new();
961        MV.get_or_init(|| BigInt::from(10).pow(LA_MAXVAL_WORDS_EXP))
962    }
963
964    /// Python's `to_cardinal`.
965    ///
966    /// The original works on `str(number)`: it peels a leading `"-"` off the
967    /// *string*, then re-parses with `int()`. For integral input that is
968    /// exactly "take the sign, then the absolute value", which is what this
969    /// does — no digit round-trip needed. The `"." in n` branch is the float
970    /// path and cannot trigger for a `BigInt`; the dispatcher additionally
971    /// gates the Rust fast path on `type(number) is int`, so decimals never
972    /// arrive here at all.
973    /// PR #657 `to_cardinal` — masc.nom.sg. citation form by default.
974    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
975        la_cardinal_int(value, "m", "nom", true)
976    }
977
978    /// PR #657 `to_cardinal(gender=, case=, macrons=)`.
979    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
980        let (g, c, m) = la_grammar_kw(kw)?;
981        la_cardinal_int(value, g, c, m)
982    }
983
984    /// PR #657 `to_ordinal` — declined; compound values fall back to cardinal.
985    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
986        la_ordinal_int(value, "m", "nom", true)
987    }
988
989    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
990        let (g, c, m) = la_grammar_kw(kw)?;
991        la_ordinal_int(value, g, c, m)
992    }
993
994    /// PR #657 `to_ordinal_num` — a Roman numeral.
995    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
996        la_ordinal_num_int(value)
997    }
998
999    /// PR #657 `to_year(macrons=)` — negatives take an "ante Chrīstum" prefix.
1000    fn to_year(&self, value: &BigInt) -> Result<String> {
1001        la_year_int(value, true)
1002    }
1003
1004    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1005        if !kw.only(&["macrons"]) {
1006            return Err(N2WError::Type(
1007                "to_year() got an unexpected keyword argument".into(),
1008            ));
1009        }
1010        la_year_int(value, kw.bool("macrons").unwrap_or(true))
1011    }
1012
1013    // ---- currency ----------------------------------------------------
1014
1015    /// `self.__class__.__name__`, for the NotImplementedError message that
1016    /// `to_cheque` raises on an unknown code.
1017    fn lang_name(&self) -> &str {
1018        "Num2Word_LA"
1019    }
1020
1021    /// `self.CURRENCY_FORMS[code]` — a **strict** lookup, `None` for a miss.
1022    ///
1023    /// This is the hook the inherited `Num2Word_Base.to_cheque` reaches, and it
1024    /// wants the strict semantics: base does `self.CURRENCY_FORMS[currency]`
1025    /// inside a `try`/`except KeyError` and re-raises NotImplementedError. LA's
1026    /// *own* `to_currency` is the lenient one (`.get(code, <EUR>)`, see bug 10)
1027    /// and does its fallback inline rather than through this hook — which is
1028    /// why `currency:GBP` yields "euros" while `cheque:GBP` raises. Both
1029    /// behaviours are in the corpus; they are not reconcilable into one hook.
1030    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1031        self.currency_forms.get(code)
1032    }
1033
1034    /// Python's `Num2Word_LA.to_currency`. Overrides `Num2Word_Base`'s wholesale
1035    /// — no `parse_currency_parts`, no `pluralize`, no `CURRENCY_PRECISION`, no
1036    /// `CURRENCY_ADJECTIVES`.
1037    ///
1038    /// ```python
1039    /// def to_currency(self, val, currency="EUR", cents=True,
1040    ///                 separator=" ", adjective=False):
1041    ///     is_negative = False
1042    ///     if val < 0:
1043    ///         is_negative = True
1044    ///         val = abs(val)
1045    ///     parts = str(val).split(".")
1046    ///     left = int(parts[0]) if parts[0] else 0
1047    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
1048    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
1049    ///         currency, list(self.CURRENCY_FORMS.values())[0]
1050    ///     )
1051    ///     left_str = self._int_to_word(left)
1052    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
1053    ///     if cents and right:
1054    ///         cents_str = self._int_to_word(right)
1055    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
1056    ///     if is_negative:
1057    ///         result = self.negword + result
1058    ///     return result.strip()
1059    /// ```
1060    fn to_currency(
1061        &self,
1062        val: &CurrencyValue,
1063        currency: &str,
1064        cents: bool,
1065        separator: Option<&str>,
1066        adjective: bool,
1067    ) -> Result<String> {
1068        // Trait now hands us None when the caller omitted separator=;
1069        // resolve it to this language's own default before the ported body.
1070        let separator = separator.unwrap_or(self.default_separator());
1071        // `adjective` is accepted and then never read — LA's signature declares
1072        // it purely for interface compatibility with `Num2Word_Base`, and
1073        // `CURRENCY_ADJECTIVES` is `{}` on this class regardless. Verified:
1074        // `to_currency(12.34, adjective=True)` is byte-identical to the plain
1075        // call. See bug 13.
1076        let _ = adjective;
1077
1078        let (left, right, is_negative) = Self::split_value(val)?;
1079
1080        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — bug 10.
1081        let forms = self.currency_forms.get(currency).unwrap_or(&self.eur);
1082        let (cr1, cr2) = (&forms.unit, &forms.subunit);
1083
1084        let one = BigInt::from(1u32);
1085        let left_str = self.int_to_word(&left);
1086        // `cr1[1] if left != 1 else cr1[0]`.
1087        let mut result = format!(
1088            "{} {}",
1089            left_str,
1090            if left == one { &cr1[0] } else { &cr1[1] }
1091        );
1092
1093        // `if cents and right:` — note this is *not* base's cents=False
1094        // ("render the subunit as terse digits"). For LA, `cents=False` drops
1095        // the subunit segment outright, so `_cents_terse`/`_cents_verbose` are
1096        // never reached and the `cents_terse` hook stays at its trait default.
1097        // Verified: `to_currency(12.34, cents=False)` -> "decem duo euros".
1098        // See bug 14. `right` is falsy at 0, which is what makes `1.0` render
1099        // without cents.
1100        if cents && right != 0 {
1101            let cents_str = self.int_to_word(&BigInt::from(right));
1102            let separator = if separator == SEPARATOR_UNSET {
1103                " "
1104            } else {
1105                separator
1106            };
1107            // `result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])`
1108            // — the separator is concatenated raw, with no space of its own.
1109            result.push_str(separator);
1110            result.push_str(&cents_str);
1111            result.push(' ');
1112            result.push_str(if right == 1 { &cr2[0] } else { &cr2[1] });
1113        }
1114
1115        if is_negative {
1116            // `self.negword + result`; NEGWORD already carries its trailing
1117            // space, so no extra separator is inserted.
1118            result.insert_str(0, NEGWORD);
1119        }
1120
1121        // Python's trailing `.strip()`. Reachable only in principle: every
1122        // branch above produces a non-space-padded string. Kept for fidelity.
1123        Ok(result.trim().to_string())
1124    }
1125}
1126
1127#[cfg(test)]
1128mod la_pr657_tests {
1129    use super::*;
1130    use crate::base::Kwargs;
1131
1132    fn card(v: i64) -> String {
1133        LangLa::new().to_cardinal(&BigInt::from(v)).unwrap()
1134    }
1135    fn ordn(v: i64) -> String {
1136        LangLa::new().to_ordinal_num(&BigInt::from(v)).unwrap()
1137    }
1138
1139    #[test]
1140    fn cardinals_decline_with_macrons() {
1141        assert_eq!(card(0), "nullus");
1142        assert_eq!(card(1), "ūnus");
1143        assert_eq!(card(50), "quīnquāgintā");
1144        assert_eq!(card(100), "centum");
1145        assert_eq!(card(200), "ducentī");
1146        assert_eq!(card(2024), "duo mīlia vīgintī quattuor");
1147        assert_eq!(
1148            LangLa::new().to_cardinal(&BigInt::from(38_630_666)).unwrap(),
1149            "trīgintā octō miliones sescenta trīgintā mīlia sescentī sexāgintā sex"
1150        );
1151    }
1152
1153    #[test]
1154    fn gender_and_case_kwargs() {
1155        let mut kw = Kwargs::default();
1156        kw.0.push(("gender".into(), crate::base::KwVal::Str("f".into())));
1157        kw.0.push(("case".into(), crate::base::KwVal::Str("abl".into())));
1158        assert_eq!(LangLa::new().to_cardinal_kw(&BigInt::from(1), &kw).unwrap(), "ūnā");
1159    }
1160
1161    #[test]
1162    fn macrons_false_strips_diacritics() {
1163        let mut kw = Kwargs::default();
1164        kw.0.push(("macrons".into(), crate::base::KwVal::Bool(false)));
1165        assert_eq!(LangLa::new().to_cardinal_kw(&BigInt::from(50), &kw).unwrap(), "quinquaginta");
1166    }
1167
1168    #[test]
1169    fn ordinal_num_is_roman() {
1170        assert_eq!(ordn(4), "IV");
1171        assert_eq!(ordn(2024), "MMXXIV");
1172        assert_eq!(ordn(0), "0");
1173    }
1174
1175    #[test]
1176    fn ordinals_decline() {
1177        assert_eq!(LangLa::new().to_ordinal(&BigInt::from(3)).unwrap(), "tertius");
1178        assert_eq!(LangLa::new().to_ordinal(&BigInt::from(20)).unwrap(), "vīcēsimus");
1179    }
1180
1181    #[test]
1182    fn year_bc_prefix() {
1183        assert_eq!(LangLa::new().to_year(&BigInt::from(-44)).unwrap(), "ante Chrīstum quadrāgintā quattuor");
1184    }
1185
1186    #[test]
1187    fn overflow_past_1e18() {
1188        let big = BigInt::from(10).pow(18);
1189        assert!(matches!(LangLa::new().to_cardinal(&big), Err(N2WError::Overflow(_))));
1190    }
1191}