Skip to main content

num2words2_core/
lang_lv.rs

1//! Port of `lang_LV.py` (Latvian).
2//!
3//! Registry check: `CONVERTER_CLASSES["lv"]` is `lang_LV.Num2Word_LV`, which is
4//! the class ported here.
5//!
6//! Shape: **self-contained**. `Num2Word_LV` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
8//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
9//! outright and drives `_int2word` over 3-digit chunks. Consequently
10//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
11//! **no overflow check** — the only ceiling is the `THOUSANDS` table (keys
12//! 1..=10), which raises `KeyError` rather than `OverflowError`. See bug 4.
13//!
14//! Inherited from `Num2Word_Base` (unchanged by LV, so the trait defaults do
15//! the right thing):
16//!   * `to_ordinal_num(value) -> value`  → default `Ok(value.to_string())`.
17//!     Verified: Python returns the *int* unchanged, and the corpus records
18//!     its `str()` — including the sign, e.g. `-7` → "-7".
19//!   * `to_year(value)        -> self.to_cardinal(value)` → default delegates
20//!     through `&self`, picking up the `to_cardinal` override below. LV has no
21//!     era/apposition handling, so years are plain cardinals: `to_year(-44)`
22//!     == "mīnus četrdesmit četri".
23//!
24//! `setup()` sets `negword = "mīnus"` and `pointword = "komats"`.
25//!
26//! # Faithfully reproduced Python oddities
27//!
28//! This is a port, not a rewrite. All of the following are what Python emits,
29//! verified against the interpreter:
30//!
31//! 1. `THOUSANDS[7]` is **"sikstiljons"** — a typo for "sekstiljons". Kept
32//!    verbatim; it is corpus-visible (`to_cardinal(10**21)` == "sikstiljons").
33//!    `THOUSANDS[10]` is likewise "nontiljons" (rather than "noniljons").
34//! 2. `to_ordinal` is, in the module's own words, "a simplified
35//!    implementation": outside its 28-entry lookup table it just strips a
36//!    trailing "s" from the cardinal and glues on "ais". This produces
37//!    non-words for most inputs, e.g. `to_ordinal(0)` == "nulleais",
38//!    `to_ordinal(200)` == "divi simtiais", `to_ordinal(2000)` ==
39//!    "divi tūkstošiais". All corpus-confirmed. Do not "fix" these.
40//! 3. `to_ordinal` accepts negatives (unlike e.g. Polish) because it routes
41//!    through `to_cardinal`, which strips the sign before chunking. The suffix
42//!    is then appended to the whole phrase: `to_ordinal(-7)` ==
43//!    "mīnus septiņiais", `to_ordinal(-1)` == "mīnus vienais". Corpus-confirmed.
44//! 4. There is no `MAXVAL`. `_int2word` indexes `THOUSANDS[i]` with the chunk
45//!    index, and the table stops at 10. The first non-zero chunk at `i >= 11`
46//!    therefore raises `KeyError` — this is Latvian's de facto (and abrupt)
47//!    ceiling at 10^33. Verified: `10**32` → "simts nontiljoni" but `10**33`
48//!    → `KeyError: 11` and `10**36` → `KeyError: 12`. The key in the message is
49//!    the chunk index, not the value. Modelled by [`LangLv::thousands_at`].
50//! 5. `pluralize`'s third form (`forms[2]`, the genitive "tūkstošu"/"miljonu"
51//!    column) is **dead code** on the `_int2word` path: `form` is 2 only when
52//!    `n == 0`, but `_int2word` `continue`s on zero chunks before ever calling
53//!    it. It is very much *alive* on the currency path, though — `0.01 USD` is
54//!    "nulle **dolāru**, viens cents", genitive, because `pluralize(0, cr1)`
55//!    lands on form 2. Corpus-confirmed.
56//! 6. `to_currency`'s integer fast path **bypasses `pluralize` entirely**,
57//!    hardcoding `cr1[0]` for `abs(val) == 1` and `cr1[1]` otherwise. That
58//!    disagrees with LV's own plural rule in both directions, and the corpus
59//!    pins the buggy answers:
60//!      * `to_currency(0, "USD")` == "nulle **dolāri**" — `pluralize(0)` would
61//!        say "dolāru" (form 2).
62//!      * `to_currency(21, "USD")` == "divdesmit viens **dolāri**" —
63//!        `pluralize(21)` would say "dolārs" (form 0, since 21 % 10 == 1).
64//!    The float path *does* call `pluralize`, so the two paths disagree with
65//!    each other. Verified against the interpreter; do not unify them.
66//! 7. The same integer fast path silently **ignores the `adjective` kwarg**:
67//!    it never consults `CURRENCY_ADJECTIVES`. Verified:
68//!    `to_currency(2, "USD", adjective=True)` == "divi dolāri", while the float
69//!    `to_currency(2.5, "USD", adjective=True)` == "divi ASV dolāri, piecdesmit
70//!    centi". `cents` and `separator` are likewise dead on the integer path.
71//!
72//! # The float/Decimal cardinal path — and the fractional-cents gap left open
73//!
74//! `Num2Word_LV.to_cardinal` reads a decimal part as a *whole number*, padding
75//! one "nulle" per leading zero (`0.19` → "nulle komats **deviņpadsmit**",
76//! `0.3456` → "...trīs tūkstoši četri simti piecdesmit seši"), NOT digit by
77//! digit like `Num2Word_Base` (`0.19` → "nulle komats **viens deviņi**"). This
78//! is now reproduced by the [`LangLv::to_cardinal_float`] override, so the
79//! `cardinal`/`cardinal_dec` corpus rows pass (`12.34` → "...komats trīsdesmit
80//! četri", `2.675` → "...komats seši simti septiņdesmit pieci").
81//!
82//! **Still deliberately left at the trait default**: `cardinal_from_decimal`,
83//! the fractional-cents entry point (`Num2Word_Base.to_currency`'s
84//! `self.to_cardinal(float(right))` branch). It routes to Base's digit-by-digit
85//! `cardinal_from_bigdecimal`, so it is **wrong and quiet** for LV. It coincides
86//! with LV's whole-number reading whenever the fraction is leading-zeros plus a
87//! single significant digit, and diverges from two significant digits on.
88//! Reachable only for currency inputs with more decimals than the currency has
89//! (`1.011 USD`, `0.0019 EUR`); no corpus row exercises it — every LV
90//! currency/cheque arg has <= 2 decimals — so this is latent, not a live
91//! failure, and remains out of scope for this port.
92//!
93//! # Hundreds spelling rule
94//!
95//! `HUNDRED` carries three forms — ("simts", "simti", "simtu") — selected by a
96//! three-way branch that is easy to misread, so spelled out here:
97//!   * `n3 == 1, n2 == 0, n1 > 0` → "simtu" (101 → "simtu viens")
98//!   * `n3 > 1`                   → ONES + "simti" (200 → "divi simti")
99//!   * otherwise (`n3 == 1`)      → "simts" (100 → "simts"; 110 → "simts
100//!     desmit", because `n2 != 0` drops it out of the "simtu" arm)
101
102use crate::base::{Lang, N2WError, Result};
103use crate::currency::{CurrencyForms, CurrencyValue};
104use crate::floatpath::{float2tuple, FloatValue};
105use crate::strnum::ParsedNumber;
106use num_bigint::BigInt;
107use num_integer::Integer;
108use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
109use std::collections::HashMap;
110
111const ZERO: &str = "nulle";
112const NEGWORD: &str = "mīnus";
113const POINTWORD: &str = "komats";
114
115/// `ONES`, keys 1..=9. Index 0 is absent in Python (guarded by `n1 > 0` /
116/// `n3 > 1`).
117const ONES: [&str; 10] = [
118    "", "viens", "divi", "trīs", "četri", "pieci", "seši", "septiņi", "astoņi", "deviņi",
119];
120
121/// `TENS`, keys 0..=9 — the 10..19 teens, keyed by the *units* digit.
122const TENS: [&str; 10] = [
123    "desmit",
124    "vienpadsmit",
125    "divpadsmit",
126    "trīspadsmit",
127    "četrpadsmit",
128    "piecpadsmit",
129    "sešpadsmit",
130    "septiņpadsmit",
131    "astoņpadsmit",
132    "deviņpadsmit",
133];
134
135/// `TWENTIES`, keys 2..=9. Indices 0/1 are absent in Python (guarded `n2 > 1`).
136const TWENTIES: [&str; 10] = [
137    "",
138    "",
139    "divdesmit",
140    "trīsdesmit",
141    "četrdesmit",
142    "piecdesmit",
143    "sešdesmit",
144    "septiņdesmit",
145    "astoņdesmit",
146    "deviņdesmit",
147];
148
149/// `HUNDRED` — ("simts", "simti", "simtu"). See the module docs for the
150/// selection rule.
151const HUNDRED: [&str; 3] = ["simts", "simti", "simtu"];
152
153/// `THOUSANDS`: chunk index → (nominative sg, nominative pl, genitive pl).
154/// Keys 1..=10, i.e. up to 1000^10 == 10^30 ("nontiljons"). Index 0 is absent
155/// in Python and unreachable (guarded by `i > 0`); index >= 11 is a `KeyError`.
156///
157/// Key 7 ships "sikstiljons"; key 10 ships "nontiljons". Both are Python's
158/// spelling — see bug 1 in the module docs.
159const THOUSANDS: [[&str; 3]; 11] = [
160    ["", "", ""], // absent in Python
161    ["tūkstotis", "tūkstoši", "tūkstošu"],
162    ["miljons", "miljoni", "miljonu"],
163    ["miljards", "miljardi", "miljardu"],
164    ["triljons", "triljoni", "triljonu"],
165    ["kvadriljons", "kvadriljoni", "kvadriljonu"],
166    ["kvintiljons", "kvintiljoni", "kvintiljonu"],
167    ["sikstiljons", "sikstiljoni", "sikstiljonu"], // sic — Python typo
168    ["septiljons", "septiljoni", "septiljonu"],
169    ["oktiljons", "oktiljoni", "oktiljonu"],
170    ["nontiljons", "nontiljoni", "nontiljonu"], // sic — Python spelling
171];
172
173/// The `ordinals` dict literal in `Num2Word_LV.to_ordinal`, in source order.
174///
175/// Sparse: 1..=20, then the round tens 30..=90, then 100 and 1000 — 29 entries.
176/// Anything else falls through to the "strip s, append ais" fallback. Note the
177/// absence of 0 — hence `to_ordinal(0)` == "nulleais" (bug 2).
178const ORDINALS: [(i64, &str); 29] = [
179    (1, "pirmais"),
180    (2, "otrais"),
181    (3, "trešais"),
182    (4, "ceturtais"),
183    (5, "piektais"),
184    (6, "sestais"),
185    (7, "septītais"),
186    (8, "astotais"),
187    (9, "devītais"),
188    (10, "desmitais"),
189    (11, "vienpadsmitais"),
190    (12, "divpadsmitais"),
191    (13, "trīspadsmitais"),
192    (14, "četrpadsmitais"),
193    (15, "piecpadsmitais"),
194    (16, "sešpadsmitais"),
195    (17, "septiņpadsmitais"),
196    (18, "astoņpadsmitais"),
197    (19, "deviņpadsmitais"),
198    (20, "divdesmitais"),
199    (30, "trīsdesmitais"),
200    (40, "četrdesmitais"),
201    (50, "piecdesmitais"),
202    (60, "sešdesmitais"),
203    (70, "septiņdesmitais"),
204    (80, "astoņdesmitais"),
205    (90, "deviņdesmitais"),
206    (100, "simtais"),
207    (1000, "tūkstošais"),
208];
209
210// --- Currency ------------------------------------------------------------
211//
212// `Num2Word_LV` subclasses `Num2Word_Base`, **not** `Num2Word_EUR`, and
213// declares its own `CURRENCY_FORMS` in its class body. So the shared-mutable
214// -EUR-dict trap does not apply here: `Num2Word_EN.__init__`'s in-place writes
215// land on `Num2Word_EUR.CURRENCY_FORMS`, a different object. Confirmed against
216// the live interpreter — LV's runtime table is exactly the source literal, 13
217// entries, with none of EN's ~24 additions. Hence no JPY, KWD, BHD, INR, CNY
218// or CHF, all of which raise NotImplementedError (corpus-confirmed).
219//
220// The module-level tuples from `lang_LV.py`, shared by several codes.
221const GENERIC_DOLLARS: [&str; 3] = ["dolārs", "dolāri", "dolāru"];
222const GENERIC_CENTS: [&str; 3] = ["cents", "centi", "centu"];
223const GENERIC_KRONA: [&str; 3] = ["krona", "kronas", "kronu"];
224const GENERIC_ERA: [&str; 3] = ["ēre", "ēras", "ēru"];
225
226/// `Num2Word_LV.CURRENCY_FORMS`, in source order.
227///
228/// Every entry carries **three** forms on both sides, and all three are load
229/// bearing: `pluralize` indexes 0/1/2, and `to_cheque` takes `cr1[-1]` — so
230/// `cheque:USD` is "...DOLĀRU", the genitive. Dropping the third form would
231/// silently change both.
232fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
233    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
234    m.insert("AUD", CurrencyForms::new(&GENERIC_DOLLARS, &GENERIC_CENTS));
235    m.insert("CAD", CurrencyForms::new(&GENERIC_DOLLARS, &GENERIC_CENTS));
236    // replaced by EUR
237    m.insert("EEK", CurrencyForms::new(&GENERIC_KRONA, &GENERIC_CENTS));
238    m.insert("EUR", CurrencyForms::new(&["eiro", "eiro", "eiro"], &GENERIC_CENTS));
239    // The legal/finance spelling mandated for LV EU documents — see the class
240    // docstring's citations. A distinct code, not a variant of EUR.
241    m.insert(
242        "EUR_LEGAL",
243        CurrencyForms::new(&["euro", "euro", "euro"], &GENERIC_CENTS),
244    );
245    m.insert(
246        "GBP",
247        CurrencyForms::new(
248            &["sterliņu mārciņa", "sterliņu mārciņas", "sterliņu mārciņu"],
249            &["penss", "pensi", "pensu"],
250        ),
251    );
252    // replaced by EUR
253    m.insert("LTL", CurrencyForms::new(&["lits", "liti", "litu"], &GENERIC_CENTS));
254    // replaced by EUR
255    m.insert(
256        "LVL",
257        CurrencyForms::new(&["lats", "lati", "latu"], &["santīms", "santīmi", "santīmu"]),
258    );
259    m.insert("USD", CurrencyForms::new(&GENERIC_DOLLARS, &GENERIC_CENTS));
260    m.insert(
261        "RUB",
262        CurrencyForms::new(&["rublis", "rubļi", "rubļu"], &["kapeika", "kapeikas", "kapeiku"]),
263    );
264    m.insert("SEK", CurrencyForms::new(&GENERIC_KRONA, &GENERIC_ERA));
265    m.insert("NOK", CurrencyForms::new(&GENERIC_KRONA, &GENERIC_ERA));
266    m.insert(
267        "PLN",
268        CurrencyForms::new(&["zlots", "zloti", "zlotu"], &["grasis", "graši", "grašu"]),
269    );
270    m
271}
272
273/// `Num2Word_LV.CURRENCY_ADJECTIVES`.
274///
275/// "Kreivijas" (RUB) is Python's spelling — a typo for "Krievijas". Kept
276/// verbatim. Reachable only from the float path with `adjective=True`; the
277/// integer path never reads this table at all (bug 7).
278fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
279    [
280        ("AUD", "Austrālijas"),
281        ("CAD", "Kanādas"),
282        ("EEK", "Igaunijas"),
283        ("USD", "ASV"),
284        ("RUB", "Kreivijas"), // sic — Python typo for "Krievijas"
285        ("SEK", "Zviedrijas"),
286        ("NOK", "Norvēģijas"),
287    ]
288    .into_iter()
289    .collect()
290}
291
292// LV defines no `CURRENCY_PRECISION`, so it inherits `Num2Word_Base`'s empty
293// `{}` and every code resolves to `.get(code, 100)` == 100. The trait's default
294// already returns 100, so `currency_precision` is deliberately **not**
295// overridden. Consequently LV has neither a 0-decimal nor a 3-decimal currency:
296// the `divisor == 1` and `divisor == 1000` branches are both unreachable here,
297// and JPY/KWD/BHD raise NotImplementedError long before precision matters.
298
299// --- Python exception encoding -------------------------------------------
300//
301// This is a crash site, not a deliberate raise: `THOUSANDS[i]` on a missing
302// key. The exception *type* is observable behaviour a caller may catch, so
303// parity requires reproducing KeyError rather than tidying it into a
304// TypeError or an OverflowError.
305fn key_error(key: String) -> N2WError {
306    N2WError::Key(key)
307}
308
309/// Port of `utils.splitbyx(n, x)` with `format_int=True`.
310///
311/// Unlike Polish, LV only ever reaches this with a **non-negative** value:
312/// `to_cardinal` strips the sign before calling `_int2word`, and `to_ordinal`
313/// routes through `to_cardinal`. So `str(n)` is pure digits and the `int()`
314/// call cannot raise — the `Result` here is defensive, never `Err` in practice.
315fn splitbyx(n: &str, x: usize) -> Result<Vec<BigInt>> {
316    let chars: Vec<char> = n.chars().collect();
317    let length = chars.len();
318    let slice = |i: usize, j: usize| -> String { chars[i..j.min(length)].iter().collect() };
319
320    let parse = |s: String| -> Result<BigInt> {
321        BigInt::parse_bytes(s.as_bytes(), 10)
322            .ok_or_else(|| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
323    };
324
325    let mut out: Vec<BigInt> = Vec::new();
326    if length > x {
327        let start = length % x;
328        if start > 0 {
329            out.push(parse(slice(0, start))?);
330        }
331        let mut i = start;
332        while i < length {
333            out.push(parse(slice(i, i + x))?);
334            i += x;
335        }
336    } else {
337        out.push(parse(n.to_string())?);
338    }
339    Ok(out)
340}
341
342/// Port of `utils.get_digits(n)`:
343/// `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` → `[n1, n2, n3]`
344/// (units, tens, hundreds).
345///
346/// Only ever called with a chunk in 0..=999 (see [`splitbyx`]), so the
347/// zero-padded form is exactly 3 chars and the `[-3:]` slice is the whole
348/// string. No sign hazard here.
349fn get_digits(n: &BigInt) -> [usize; 3] {
350    let s = format!("{:03}", n);
351    let chars: Vec<char> = s.chars().collect();
352    let tail = &chars[chars.len() - 3..];
353    let mut a = [0usize; 3];
354    for (k, c) in tail.iter().rev().enumerate() {
355        a[k] = c.to_digit(10).unwrap_or(0) as usize;
356    }
357    a
358}
359
360pub struct LangLv {
361    currency_forms: HashMap<&'static str, CurrencyForms>,
362    currency_adjectives: HashMap<&'static str, &'static str>,
363}
364
365impl Default for LangLv {
366    fn default() -> Self {
367        Self::new()
368    }
369}
370
371impl LangLv {
372    pub fn new() -> Self {
373        LangLv {
374            // Built once here, never per call. `to_currency`/`to_cheque` only
375            // ever read these tables; rebuilding them on every call is what
376            // made an earlier revision of this port slower than the Python it
377            // replaces.
378            currency_forms: build_currency_forms(),
379            currency_adjectives: build_currency_adjectives(),
380        }
381    }
382
383    /// `THOUSANDS[i]`, raising `KeyError` past 10. See bug 4 in the module docs.
384    fn thousands_at(&self, i: usize) -> Result<&'static [&'static str; 3]> {
385        if (1..=10).contains(&i) {
386            Ok(&THOUSANDS[i])
387        } else {
388            Err(key_error(i.to_string()))
389        }
390    }
391
392    /// Port of `Num2Word_LV.pluralize`.
393    ///
394    /// ```python
395    /// form = 0 if (n % 10 == 1 and n % 100 != 11) else 1 if n != 0 else 2
396    /// ```
397    /// `n` is a 3-digit chunk and always non-negative here, but `mod_floor` is
398    /// used anyway to keep Python's `%` semantics rather than Rust's `%`.
399    /// Form 2 is unreachable from `_int2word` — see bug 5 in the module docs.
400    fn pluralize(&self, n: &BigInt, forms: &[&str; 3]) -> String {
401        let m10 = n.mod_floor(&BigInt::from(10));
402        let m100 = n.mod_floor(&BigInt::from(100));
403        let form = if m10.is_one() && m100 != BigInt::from(11) {
404            0usize
405        } else if !n.is_zero() {
406            1
407        } else {
408            2
409        };
410        forms[form].to_string()
411    }
412
413    /// Port of `Num2Word_LV._int2word`. Called only with non-negative values.
414    fn int2word(&self, n: &BigInt) -> Result<String> {
415        if n.is_zero() {
416            return Ok(ZERO.to_string());
417        }
418
419        let mut words: Vec<String> = Vec::new();
420        let chunks = splitbyx(&n.to_string(), 3)?;
421        let mut i = chunks.len();
422        for x in chunks.iter() {
423            i -= 1;
424
425            if x.is_zero() {
426                continue;
427            }
428
429            let [n1, n2, n3] = get_digits(x);
430
431            if n3 > 0 {
432                if n3 == 1 && n2 == 0 && n1 > 0 {
433                    words.push(HUNDRED[2].to_string());
434                } else if n3 > 1 {
435                    words.push(ONES[n3].to_string());
436                    words.push(HUNDRED[1].to_string());
437                } else {
438                    words.push(HUNDRED[0].to_string());
439                }
440            }
441
442            if n2 > 1 {
443                words.push(TWENTIES[n2].to_string());
444            }
445
446            if n2 == 1 {
447                words.push(TENS[n1].to_string());
448            } else if n1 > 0 && !(i > 0 && x.is_one()) {
449                // `x == 1` (the whole chunk, not just the units digit) is what
450                // suppresses "viens" in "tūkstotis" / "miljons". 21000 keeps it
451                // ("divdesmit viens tūkstotis") because x == 21, not 1.
452                words.push(ONES[n1].to_string());
453            }
454
455            if i > 0 {
456                let forms = self.thousands_at(i)?;
457                words.push(self.pluralize(x, forms));
458            }
459        }
460
461        Ok(words.join(" "))
462    }
463}
464
465/// `int(number)`'s truncation toward zero, for the finite float and Decimal
466/// arms. `None` only for a non-finite float (Python's `int()` raises there —
467/// see the callers for which exception).
468fn trunc_to_bigint(v: &FloatValue) -> Option<BigInt> {
469    match v {
470        FloatValue::Float { value, .. } => {
471            if !value.is_finite() {
472                return None;
473            }
474            BigInt::from_f64(value.trunc())
475        }
476        FloatValue::Decimal { value, .. } => {
477            // `with_scale(0)` truncates toward zero — exactly `int(Decimal)`.
478            Some(value.with_scale(0).as_bigint_and_exponent().0)
479        }
480    }
481}
482
483/// `int(n)`'s ValueError for the no-`"."` branch of `to_cardinal`, reached
484/// when `str(number)` came out in exponent form (`1e+16`, `1E+2`) or as
485/// `inf`/`nan`/`Infinity`. Message shape matches CPython's; the corpus checks
486/// exception types.
487fn int_value_error(literal: &str) -> N2WError {
488    N2WError::Value(format!(
489        "invalid literal for int() with base 10: '{}'",
490        literal
491    ))
492}
493
494/// `str(number)` for a float carrying no visible point: exponent form for
495/// finite values, `inf`/`nan` otherwise. Only feeds the ValueError message.
496fn float_no_point_str(f: f64) -> String {
497    if f.is_nan() {
498        return "nan".to_string();
499    }
500    if f.is_infinite() {
501        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
502    }
503    let s = format!("{:e}", f);
504    match s.split_once('e') {
505        Some((m, e)) if !e.starts_with('-') => format!("{}e+{:0>2}", m, e),
506        Some((m, e)) => format!("{}e-{:0>2}", m, &e[1..]),
507        None => s,
508    }
509}
510
511impl Lang for LangLv {
512    /// This language's own `to_currency(currency=...)` default,
513    /// read from the live Python signature. Only 44 of 156 use EUR.
514    fn default_currency(&self) -> &str {
515        "EUR"
516    }
517
518    /// This language's own `to_currency(separator=...)` default,
519    /// read from the live Python signature. Base's is ",", but only
520    /// 36 of 149 languages actually use it — most default to " " or a
521    /// conjunction, so inheriting Base's comma silently corrupts them.
522    fn default_separator(&self) -> &str {
523        ","
524    }
525
526    fn negword(&self) -> &str {
527        NEGWORD
528    }
529
530    fn pointword(&self) -> &str {
531        "komats"
532    }
533
534    /// Port of `Num2Word_LV.to_cardinal`, integer path only.
535    ///
536    /// Python stringifies the input, swaps "," → "." and looks for "."; a
537    /// `str(int)` contains neither, so integers always take the `else` branch:
538    /// `"%s%s" % (base_str, self._int2word(int(n)))`. The float branch
539    /// (`pointword`, leading-zero "nulle" padding) is out of scope.
540    ///
541    /// `parse_minus` returns `"%s " % self.negword.strip()` — i.e. "mīnus "
542    /// *with* the trailing space — which is why the two `%s` concatenate
543    /// directly with no separator.
544    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
545        if value.is_negative() {
546            let words = self.int2word(&value.abs())?;
547            Ok(format!("{} {}", NEGWORD, words))
548        } else {
549            self.int2word(value)
550        }
551    }
552
553    /// Port of `Num2Word_LV.to_ordinal`.
554    ///
555    /// The `try: int(number) except (ValueError, TypeError): return str(number)`
556    /// guard is unreachable for integer input and is not modelled.
557    ///
558    /// Outside the `ordinals` table this is the module's self-described
559    /// "simplified implementation": strip one trailing "s" from the cardinal,
560    /// then append "ais" — see bug 2 in the module docs.
561    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
562        // `if num in ordinals` — the table is keyed by exact int value. A value
563        // too large for i64 simply cannot be in a table topping out at 1000.
564        if let Some(k) = value.to_i64() {
565            if let Some((_, word)) = ORDINALS.iter().find(|(key, _)| *key == k) {
566                return Ok(word.to_string());
567            }
568        }
569
570        let cardinal = self.to_cardinal(value)?;
571        // Python: `cardinal[:-1] + "ais"` — drops exactly one character, which
572        // is the ASCII "s" we just matched, so this is char-safe.
573        match cardinal.strip_suffix('s') {
574            Some(stem) => Ok(format!("{}ais", stem)),
575            None => Ok(format!("{}ais", cardinal)),
576        }
577    }
578
579    /// Port of the **float/Decimal branch** of `Num2Word_LV.to_cardinal`.
580    ///
581    /// LV overrides Python's `to_cardinal` (not `to_cardinal_float`) and reads
582    /// `str(number)` directly:
583    ///
584    /// ```python
585    /// n = str(number).replace(",", ".")
586    /// base_str, n = self.parse_minus(n)
587    /// if "." in n:
588    ///     left, right = n.split(".")
589    ///     leading_zero_count = len(right) - len(right.lstrip("0"))
590    ///     decimal_part = (ZERO[0] + " ") * leading_zero_count + self._int2word(int(right))
591    ///     return "%s%s %s %s" % (base_str, self._int2word(int(left)), self.pointword, decimal_part)
592    /// else:
593    ///     return "%s%s" % (base_str, self._int2word(int(n)))
594    /// ```
595    ///
596    /// This reads the fractional part as **one whole number** with a "nulle"
597    /// prefix per leading zero (`12.34` → "...komats **trīsdesmit četri**"),
598    /// unlike `Num2Word_Base`'s digit-by-digit reading (which would give
599    /// "...komats trīs četri"). That is why this override exists — the Base
600    /// default this file previously inherited was wrong for LV.
601    ///
602    /// `float2tuple` (Base's binary/Decimal arithmetic) reproduces `int(left)`
603    /// as `pre` and `int(right)` as `post`, artefacts included: `2.675` floors
604    /// to `674.9999…` and the `< 0.01` heuristic rescues it to `675`, matching
605    /// `str(2.675) == "2.675"`. Padding `post` to `precision` chars reconstructs
606    /// the exact `right` substring `str(number)` would have produced.
607    ///
608    /// `precision_override` is **dropped on purpose**: LV's reader takes the
609    /// digits from `str(number)`, so the `precision=` kwarg has no effect on it.
610    /// Verified live: `num2words(1.2345, lang='lv', precision=2)` still yields
611    /// the full "…divi tūkstoši trīs simti četrdesmit pieci".
612    ///
613    /// Sign follows `value.is_negative()`, standing in for Python's
614    /// `str(number).startswith("-")`. These agree except for negative zero
615    /// (`str(-0.0) == "-0.0"` starts with "-" but `-0.0 < 0.0` is false) — an
616    /// input no corpus row exercises; see concerns.
617    fn to_cardinal_float(
618        &self,
619        value: &FloatValue,
620        _precision_override: Option<u32>,
621    ) -> Result<String> {
622        let precision = value.precision() as usize;
623
624        // `base_str` = parse_minus's `"%s " % negword.strip()` (= "mīnus ") or "".
625        let base_str = if value.is_negative() {
626            format!("{} ", NEGWORD)
627        } else {
628            String::new()
629        };
630
631        // (pre, post) == (int(value), int(right)); pre is signed, but `left` is
632        // the abs string in Python, so `int(left)` == abs(pre).
633        let (pre, post) = float2tuple(value);
634        let left_word = self.int2word(&pre.abs())?;
635
636        // `"." not in n` — only integer-scale Decimals (e.g. Decimal("5")); the
637        // else branch is `"%s%s" % (base_str, _int2word(int(n)))`.
638        if precision == 0 {
639            return Ok(format!("{}{}", base_str, left_word));
640        }
641
642        // right = post zero-padded to `precision` chars (== str(number)'s
643        // fractional substring). leading_zero_count counts its leading zeros;
644        // int(right) == post with those zeros dropped.
645        let post_str = post.to_string();
646        let right = format!(
647            "{}{}",
648            "0".repeat(precision.saturating_sub(post_str.len())),
649            post_str
650        );
651        let leading_zero_count = right.len() - right.trim_start_matches('0').len();
652
653        let mut decimal_part = String::new();
654        for _ in 0..leading_zero_count {
655            decimal_part.push_str(ZERO);
656            decimal_part.push(' ');
657        }
658        decimal_part.push_str(&self.int2word(&post)?);
659
660        // `"%s%s %s %s" % (base_str, left, pointword, decimal_part)`. LV uses the
661        // raw `self.pointword` here (no title()); is_title is false regardless.
662        Ok(format!(
663            "{}{} {} {}",
664            base_str, left_word, POINTWORD, decimal_part
665        ))
666    }
667
668    // cardinal_from_decimal (the fractional-cents currency entry point) is left
669    // at its trait default — a separate, latent gap documented in the module
670    // header, and out of scope for this port.
671
672    /// Full `to_cardinal(float/Decimal)` routing — Python's gate is
673    /// `"." in str(number)`, NOT the base default's `int(value) == value`:
674    ///
675    /// * a **visible point** (any finite float below 1e16, or a Decimal with
676    ///   positive scale) takes the fractional branch even for whole values —
677    ///   `5.0` -> "pieci komats nulle nulle" (one "nulle" per leading zero of
678    ///   the fractional string plus `_int2word(0)`), `Decimal("5.00")` ->
679    ///   three "nulle".
680    /// * **no point** funnels the whole string into `int(n)`: plain digit
681    ///   Decimals reach the integer path, while exponent forms (`str(1e16) ==
682    ///   "1e+16"`, `str(Decimal("1E+2")) == "1E+2"`) and inf/nan raise
683    ///   **ValueError**, not the base default's OverflowError.
684    fn cardinal_float_entry(
685        &self,
686        value: &FloatValue,
687        precision_override: Option<u32>,
688    ) -> Result<String> {
689        if value.has_visible_point() {
690            return self.to_cardinal_float(value, precision_override);
691        }
692        match value {
693            FloatValue::Float { value, .. } => Err(int_value_error(&float_no_point_str(*value))),
694            FloatValue::Decimal { value, .. } => {
695                let s = crate::strnum::python_decimal_str(value);
696                match crate::strnum::python_int_parse(&s) {
697                    Some(i) => self.to_cardinal(&i),
698                    None => Err(int_value_error(&s)),
699                }
700            }
701        }
702    }
703
704    /// `to_ordinal(float/Decimal)`. Python:
705    ///
706    /// ```python
707    /// try: num = int(number)
708    /// except (ValueError, TypeError): return str(number)
709    /// ```
710    ///
711    /// `int()` **truncates**: `to_ordinal(2.5)` == `to_ordinal(2)` ==
712    /// "otrais", `to_ordinal(-0.0)` == "nulleais" via the table-miss suffix
713    /// path (int(-0.0) is 0, sign gone), and `to_ordinal(1e16)` succeeds —
714    /// the ordinal table + "ais" suffix run on the truncated integer.
715    /// `int(nan)` raises ValueError, which the except arm converts to
716    /// `str(number)` == "nan"; `int(inf)` raises OverflowError, which is
717    /// *not* in the except tuple and propagates.
718    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
719        if let FloatValue::Float { value: f, .. } = value {
720            if f.is_nan() {
721                return Ok("nan".to_string());
722            }
723            if f.is_infinite() {
724                return Err(N2WError::Overflow(
725                    "cannot convert float infinity to integer".into(),
726                ));
727            }
728        }
729        let num = trunc_to_bigint(value).expect("finite after the guards above");
730        self.to_ordinal(&num)
731    }
732
733    /// `converter.str_to_number` is Base's `Decimal(value)` (LV doesn't
734    /// override it), but `Decimal("Infinity")` then hits LV's `to_cardinal`,
735    /// where `str(number)` has no "." and `int("Infinity")` raises
736    /// **ValueError** — not the OverflowError the binding's generic Inf arm
737    /// would produce. NaN already maps to ValueError there.
738    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
739        match crate::strnum::python_decimal_parse(s)? {
740            ParsedNumber::Inf { negative } => Err(int_value_error(if negative {
741                "-Infinity"
742            } else {
743                "Infinity"
744            })),
745            other => Ok(other),
746        }
747    }
748
749    // to_ordinal_num: LV does not override Num2Word_Base.to_ordinal_num, which
750    // returns the value unchanged → the trait default (`value.to_string()`) is
751    // correct, sign included.
752    //
753    // to_year: LV does not override Num2Word_Base.to_year, which delegates to
754    // to_cardinal → the trait default is correct; year_float_entry likewise
755    // stays at the default, whose delegation to cardinal_float_entry now
756    // carries LV's own str-based routing.
757
758    // ---- currency -------------------------------------------------------
759    //
760    // LV overrides `to_currency` (integer fast path) and `pluralize`, and
761    // supplies its own `CURRENCY_FORMS`/`CURRENCY_ADJECTIVES`. Everything else
762    // — `to_cheque`, `_money_verbose`, `_cents_verbose`, `_cents_terse`,
763    // `CURRENCY_PRECISION` — is inherited from `Num2Word_Base` unchanged, so
764    // the trait defaults already mirror it and are left alone.
765
766    fn lang_name(&self) -> &str {
767        "Num2Word_LV"
768    }
769
770    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
771        self.currency_forms.get(code)
772    }
773
774    fn currency_adjective(&self, code: &str) -> Option<&str> {
775        self.currency_adjectives.get(code).copied()
776    }
777
778    /// Port of `Num2Word_LV.pluralize` for the **currency** path.
779    ///
780    /// ```python
781    /// form = 0 if (n % 10 == 1 and n % 100 != 11) else 1 if n != 0 else 2
782    /// return forms[form]
783    /// ```
784    ///
785    /// Same rule as the inherent [`LangLv::pluralize`] that `_int2word` uses —
786    /// Python has one method serving both — but the trait hands forms in as
787    /// `&[String]` rather than the `&[&str; 3]` the verified integer path
788    /// carries, so the branch is restated here rather than reworking that
789    /// path's signature. Inherent methods win name resolution, so `_int2word`
790    /// keeps calling its own copy and is unaffected.
791    ///
792    /// Unlike `_int2word`, this path reaches `n == 0` (form 2, the genitive):
793    /// `0.01 USD` → "nulle **dolāru**, viens cents".
794    ///
795    /// Python indexes the tuple directly, so fewer than three forms with
796    /// `form == 2` would raise IndexError. Every LV entry has three, so this is
797    /// unreachable — mapped to `Index` rather than panicking in case the table
798    /// ever changes.
799    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
800        let m10 = n.mod_floor(&BigInt::from(10));
801        let m100 = n.mod_floor(&BigInt::from(100));
802        let form = if m10.is_one() && m100 != BigInt::from(11) {
803            0usize
804        } else if !n.is_zero() {
805            1
806        } else {
807            2
808        };
809        forms
810            .get(form)
811            .cloned()
812            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
813    }
814
815    /// Port of `Num2Word_LV.to_currency`.
816    ///
817    /// Two paths, and they do **not** agree with each other:
818    ///
819    /// * `isinstance(val, int)` → LV's own fast path, which skips `pluralize`,
820    ///   `adjective`, `cents` and `separator` (bugs 6 and 7).
821    /// * anything else → `super().to_currency(...)`, i.e. `Num2Word_Base`'s
822    ///   float path, which honours all of them.
823    ///
824    /// The int/non-int split is the same one `CurrencyValue` encodes, so it
825    /// maps across directly — `1` takes the fast path, `1.0` does not.
826    fn to_currency(
827        &self,
828        val: &CurrencyValue,
829        currency: &str,
830        cents: bool,
831        separator: Option<&str>,
832        adjective: bool,
833    ) -> Result<String> {
834        if let CurrencyValue::Int(v) = val {
835            // `try: cr1, cr2 = self.CURRENCY_FORMS[currency]`, guarded by
836            // `except (KeyError, AttributeError)` whose handler re-enters
837            // `Num2Word_Base.to_currency` and lets *that* raise the
838            // NotImplementedError. Delegating reproduces the exception type and
839            // its exact message rather than restating them here. (The
840            // AttributeError arm is dead: `CURRENCY_FORMS` always exists.)
841            let cr1 = match self.currency_forms.get(currency) {
842                Some(forms) => &forms.unit,
843                None => {
844                    return crate::currency::default_to_currency(
845                        self,
846                        val,
847                        currency,
848                        cents,
849                        separator.unwrap_or(self.default_separator()),
850                        adjective,
851                    );
852                }
853            };
854
855            // `minus_str = self.negword if val < 0 else ""` — LV takes the raw
856            // negword ("mīnus", no trailing space) where Base takes
857            // `"%s " % negword.strip()`. Same output, because the format string
858            // below spaces the three fields itself and `.strip()` eats the
859            // leading space left when minus_str is empty.
860            let minus_str = if v.is_negative() { NEGWORD } else { "" };
861            let abs_val = v.abs();
862            let money_str = self.to_cardinal(&abs_val)?;
863
864            // Bug 6: `cr1[0]` / `cr1[1]` hardcoded instead of `pluralize`.
865            // The `isinstance(cr1, tuple)` guards Python wraps these in are
866            // always true — CurrencyForms is always a sequence — so only the
867            // `len(cr1) > 1` fallback survives the translation.
868            let currency_str = if abs_val.is_one() {
869                cr1.first()
870            } else {
871                cr1.get(1).or_else(|| cr1.first())
872            }
873            .cloned()
874            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
875
876            // `("%s %s %s" % (minus_str, money_str, currency_str)).strip()`
877            return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
878                .trim()
879                .to_string());
880        }
881
882        // Floats fall through to Num2Word_Base.to_currency unchanged.
883        crate::currency::default_to_currency(
884            self,
885            val,
886            currency,
887            cents,
888            separator.unwrap_or(self.default_separator()),
889            adjective,
890        )
891    }
892}
893