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(¢s_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}