Skip to main content

num2words2_core/
lang_lt.rs

1//! Port of `lang_LT.py` (Lithuanian).
2//!
3//! Shape: **self-contained**. `Num2Word_LT` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
6//! outright and drives `_int2word` over 3-digit chunks. Consequently
7//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
8//! **no overflow check** — the only ceiling is the `THOUSANDS` table, which
9//! raises `KeyError` rather than `OverflowError` (see bug 4 below).
10//!
11//! `setup()` sets `negword = "minus"` and `pointword = "kablelis"`.
12//!
13//! Inherited from `Num2Word_Base` (unchanged by LT, so the trait defaults do
14//! the right thing):
15//!   * `to_ordinal_num(value) -> value`  → default `Ok(value.to_string())`
16//!   * `to_year(value)        -> self.to_cardinal(value)` → default delegates
17//!     through `&self`, picking up the `to_cardinal` override below.
18//!
19//! Unlike `lang_PL`, LT's `to_cardinal` strips the sign *before* the digits
20//! ever reach `splitbyx`/`get_digits`, so negatives are safe everywhere:
21//! `to_cardinal(-1)` == "minus vienas" and `to_ordinal(-1)` == "minus vienasas"
22//! rather than the `ValueError` Polish produces.
23//!
24//! # Faithfully reproduced Python bugs
25//!
26//! This is a port, not a rewrite. The following are all wrong-looking but are
27//! exactly what Python emits, verified against the interpreter:
28//!
29//! 1. **`to_ordinal` just glues "as" onto the cardinal** for anything outside
30//!    its small lookup table ("For other numbers, add 'as' suffix to the
31//!    cardinal / This is a simplified implementation"). The results are not
32//!    Lithuanian words, but they are the spec:
33//!      * `to_ordinal(0)`   == "nulisas"  (0 is absent from the table)
34//!      * `to_ordinal(21)`  == "dvidešimt vienasas"
35//!      * `to_ordinal(200)` == "du šimtaias"
36//!      * `to_ordinal(999)` == "devyni šimtai devyniasdešimt devynias"
37//!      * `to_ordinal(10**11)` == "vienas šimtas milijardųas"
38//!      * `to_ordinal(-1)`  == "minus vienasas" (sign kept, suffix appended)
39//! 2. The ordinal table itself is internally inconsistent: `100` maps to the
40//!    definite form "šimtasis" while `1000` maps to "tūkstantas" (the definite
41//!    form would be "tūkstantasis"). Both kept verbatim.
42//! 3. Typos in `THOUSANDS`, kept verbatim: index 7 is "sikstilijonas" (not
43//!    "sekstilijonas") and index 10 is "naintilijonas" — an English "nine"
44//!    transliteration where Lithuanian would use "nonilijonas". Hence
45//!    `to_cardinal(10**21)` == "vienas sikstilijonas" and `to_cardinal(10**30)`
46//!    == "vienas naintilijonas".
47//! 4. `THOUSANDS` stops at index 10, so 10^33 and above raise `KeyError`. The
48//!    key is the index of the highest non-zero chunk, i.e. `len(chunks) - 1`
49//!    (the leading chunk of `str(n)` is never zero, so it is always the one
50//!    that trips first): `to_cardinal(10**33)` → `KeyError: 11`,
51//!    `to_cardinal(10**60 + 5)` → `KeyError: 20`. This is LT's de facto (and
52//!    rather abrupt) MAXVAL: 10^33 - 1 is the largest convertible value.
53//! 5. **`ONES_FEMININE` is unreachable dead code** from every in-scope entry
54//!    point — see [`LangLt::int2word`] for the full argument. The table and the
55//!    branch are reproduced anyway so the control flow mirrors Python exactly.
56//!
57//! # Currency
58//!
59//! `Num2Word_LT` defines its own `CURRENCY_FORMS` **class attribute**, so it is
60//! not one of the 16 classes that read the dict `Num2Word_EN.__init__` mutates
61//! in place — the `lang_EUR.py` trap does not apply here. Verified against the
62//! live interpreter: LT's runtime table is exactly its source literal, six codes
63//! (LTL/EUR/USD/GBP/PLN/RUB), each with **three** forms per side.
64//!
65//! `pluralize` is LT's own Slavic-style three-way rule. `to_currency` is
66//! replaced outright and is *not* equivalent to `Num2Word_Base.to_currency` —
67//! see [`LangLt::to_currency`] for the four divergences. `to_cheque`,
68//! `_money_verbose`, `_cents_verbose`, `_cents_terse`, `CURRENCY_PRECISION`
69//! (empty) and `CURRENCY_ADJECTIVES` (empty) are all inherited unchanged, so the
70//! trait defaults already mirror them and are deliberately left alone.
71//!
72//! # Error variants
73//!
74//! Lithuanian raises two exception types in scope: `KeyError` (bug 4), mapped to
75//! `N2WError::Key`, and `NotImplementedError` for an unknown currency code,
76//! mapped to `N2WError::NotImplemented`. The former is a Python crash rather
77//! than a deliberate raise, but the exception *type* is observable, so parity
78//! means reproducing it rather than tidying it into an `OverflowError`. See
79//! [`key_error`].
80
81use crate::base::{Lang, N2WError, Result};
82use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
83use crate::floatpath::FloatValue;
84use crate::strnum::ParsedNumber;
85use bigdecimal::BigDecimal;
86use num_bigint::BigInt;
87use num_traits::{FromPrimitive, Signed, ToPrimitive, Zero};
88use std::collections::HashMap;
89
90const ZERO: &str = "nulis";
91const NEGWORD: &str = "minus";
92const POINTWORD: &str = "kablelis";
93
94/// `ONES_FEMININE`, keys 1..=9. Index 0 is absent in Python (guarded by
95/// `n1 > 0`). Unreachable in practice — see bug 5 / [`LangLt::int2word`].
96const ONES_FEMININE: [&str; 10] = [
97    "", "viena", "dvi", "trys", "keturios", "penkios", "šešios", "septynios", "aštuonios",
98    "devynios",
99];
100
101/// `ONES`, keys 1..=9. Index 0 is absent in Python (guarded by `n1 > 0` /
102/// `n3 > 0`).
103const ONES: [&str; 10] = [
104    "", "vienas", "du", "trys", "keturi", "penki", "šeši", "septyni", "aštuoni", "devyni",
105];
106
107/// `TENS`, keys 0..=9 — the teens, indexed by the *units* digit when the tens
108/// digit is 1. `TENS[0]` == "dešimt" (10).
109const TENS: [&str; 10] = [
110    "dešimt",
111    "vienuolika",
112    "dvylika",
113    "trylika",
114    "keturiolika",
115    "penkiolika",
116    "šešiolika",
117    "septyniolika",
118    "aštuoniolika",
119    "devyniolika",
120];
121
122/// `TWENTIES`, keys 2..=9. Indices 0 and 1 are absent in Python (guarded by
123/// `n2 > 1`).
124const TWENTIES: [&str; 10] = [
125    "",
126    "",
127    "dvidešimt",
128    "trisdešimt",
129    "keturiasdešimt",
130    "penkiasdešimt",
131    "šešiasdešimt",
132    "septyniasdešimt",
133    "aštuoniasdešimt",
134    "devyniasdešimt",
135];
136
137/// `HUNDRED`: (singular, plural). Selected by `n3 > 1`, so 100 → "vienas
138/// šimtas" and 200 → "du šimtai".
139const HUNDRED: [&str; 2] = ["šimtas", "šimtai"];
140
141/// `THOUSANDS`: chunk index → the three plural forms (singular, nominative
142/// plural, genitive plural). Keys 1..=10, i.e. up to 1000^10 == 10^30. Index 0
143/// is absent in Python (guarded by `i > 0`); index 11 and beyond is the
144/// `KeyError` of bug 4. Spellings at 7 and 10 are Python's typos (bug 3).
145///
146/// `static` rather than `const` so that `thousands_at` can hand out a
147/// `&'static` borrow without relying on const promotion.
148static THOUSANDS: [[&str; 3]; 11] = [
149    ["", "", ""], // absent in Python
150    ["tūkstantis", "tūkstančiai", "tūkstančių"],
151    ["milijonas", "milijonai", "milijonų"],
152    ["milijardas", "milijardai", "milijardų"],
153    ["trilijonas", "trilijonai", "trilijonų"],
154    ["kvadrilijonas", "kvadrilijonai", "kvadrilijonų"],
155    ["kvintilijonas", "kvintilijonai", "kvintilijonų"],
156    ["sikstilijonas", "sikstilijonai", "sikstilijonų"],
157    ["septilijonas", "septilijonai", "septilijonų"],
158    ["oktilijonas", "oktilijonai", "oktilijonų"],
159    ["naintilijonas", "naintilijonai", "naintilijonų"],
160];
161
162/// Python `KeyError`. `THOUSANDS[i]` past index 10 is a crash, not a
163/// deliberate raise, but the exception *type* is observable behaviour a caller
164/// may catch, so parity requires reproducing it rather than tidying it into an
165/// `OverflowError`.
166fn key_error(key: String) -> N2WError {
167    N2WError::Key(key)
168}
169
170/// `THOUSANDS[i]`, raising `KeyError` past 10.
171fn thousands_at(i: usize) -> Result<&'static [&'static str; 3]> {
172    THOUSANDS
173        .get(i)
174        .filter(|_| i >= 1)
175        .ok_or_else(|| key_error(i.to_string()))
176}
177
178/// Port of `utils.splitbyx(n, x)` with `x == 3` and `format_int=True`.
179///
180/// `n` is always the decimal string of a **non-negative** `BigInt` here:
181/// `to_cardinal` strips the sign via `parse_minus` before `_int2word` runs, so
182/// (unlike Polish) no `"-"` can reach the chunk parser and `int()` never fails.
183///
184/// Each chunk is at most 3 digits, hence `<= 999`, so `u32` is provably wide
185/// enough — the *value* stays `BigInt`, only the chunks narrow.
186fn splitbyx(n: &str) -> Vec<u32> {
187    const X: usize = 3;
188    let chars: Vec<char> = n.chars().collect();
189    let length = chars.len();
190    // Every char is an ASCII digit and every slice is <= 3 long, so parse
191    // cannot fail and cannot exceed 999.
192    let parse = |s: &[char]| -> u32 {
193        s.iter()
194            .collect::<String>()
195            .parse::<u32>()
196            .expect("splitbyx slice is always <= 3 ASCII digits")
197    };
198
199    let mut out: Vec<u32> = Vec::new();
200    if length > X {
201        let start = length % X;
202        if start > 0 {
203            out.push(parse(&chars[0..start]));
204        }
205        let mut i = start;
206        while i < length {
207            out.push(parse(&chars[i..(i + X).min(length)]));
208            i += X;
209        }
210    } else {
211        out.push(parse(&chars));
212    }
213    out
214}
215
216/// Port of `utils.get_digits(n)`:
217/// `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` → `[n1, n2, n3]`
218/// (units, tens, hundreds).
219///
220/// Callers only ever pass a 3-digit chunk (0..=999), for which `"%03d"` yields
221/// exactly three characters, so the `[-3:]` slice is total and no digit can be
222/// dropped.
223fn get_digits(n: u32) -> [usize; 3] {
224    let s = format!("{:03}", n); // "%03d" % n
225    let chars: Vec<char> = s.chars().collect();
226    let tail = &chars[chars.len() - 3..];
227    let mut a = [0usize; 3];
228    for (k, c) in tail.iter().rev().enumerate() {
229        a[k] = c.to_digit(10).expect("chunk digits are ASCII") as usize;
230    }
231    a
232}
233
234/// The `ordinals` dict rebuilt on every `Num2Word_LT.to_ordinal` call.
235///
236/// Keys: 1..=20, then the round tens 30..=90, then 100 and 1000. Anything else
237/// (including 0 and every negative) misses and falls through to the
238/// cardinal-plus-"as" path — bug 1.
239///
240/// `to_u32` returns `None` for negatives and for anything above `u32::MAX`,
241/// which is exactly the "not in ordinals" answer Python gives for those.
242fn ordinal_lookup(n: &BigInt) -> Option<&'static str> {
243    Some(match n.to_u32()? {
244        1 => "pirmas",
245        2 => "antras",
246        3 => "trečias",
247        4 => "ketvirtas",
248        5 => "penktas",
249        6 => "šeštas",
250        7 => "septintas",
251        8 => "aštuntas",
252        9 => "devintas",
253        10 => "dešimtas",
254        11 => "vienuoliktas",
255        12 => "dvyliktas",
256        13 => "tryliktas",
257        14 => "keturioliktas",
258        15 => "penkioliktas",
259        16 => "šešioliktas",
260        17 => "septynioliktas",
261        18 => "aštuonioliktas",
262        19 => "devynioliktas",
263        20 => "dvidešimtas",
264        30 => "trisdešimtas",
265        40 => "keturiasdešimtas",
266        50 => "penkiasdešimtas",
267        60 => "šešiasdešimtas",
268        70 => "septyniasdešimtas",
269        80 => "aštuoniasdešimtas",
270        90 => "devyniasdešimtas",
271        100 => "šimtasis",
272        1000 => "tūkstantas",
273        _ => return None,
274    })
275}
276
277/// `Num2Word_LT.CURRENCY_FORMS`, verbatim.
278///
279/// LT declares this as its own class attribute, so — unlike the 16 classes that
280/// inherit `Num2Word_EUR`'s dict — nothing mutates it at import time and the
281/// source literal *is* the runtime table. Confirmed by dumping
282/// `CONVERTER_CLASSES["lt"].CURRENCY_FORMS` from the live interpreter.
283///
284/// Every entry carries **three** forms per side (singular, nominative plural,
285/// genitive plural) because [`LangLt::pluralize`] indexes `forms[2]`. Dropping
286/// the third form would turn every "nulis eurų" into an IndexError.
287fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
288    // `GENERIC_CENTS` in Python — one shared tuple reused by four codes.
289    const GENERIC_CENTS: [&str; 3] = ["centas", "centai", "centų"];
290
291    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
292    m.insert(
293        "LTL",
294        CurrencyForms::new(&["litas", "litai", "litų"], &GENERIC_CENTS),
295    );
296    m.insert(
297        "EUR",
298        CurrencyForms::new(&["euras", "eurai", "eurų"], &GENERIC_CENTS),
299    );
300    m.insert(
301        "USD",
302        CurrencyForms::new(&["doleris", "doleriai", "dolerių"], &GENERIC_CENTS),
303    );
304    m.insert(
305        "GBP",
306        CurrencyForms::new(
307            &["svaras sterlingų", "svarai sterlingų", "svarų sterlingų"],
308            &["pensas", "pensai", "pensų"],
309        ),
310    );
311    m.insert(
312        "PLN",
313        CurrencyForms::new(
314            &["zlotas", "zlotai", "zlotų"],
315            &["grašis", "grašiai", "grašių"],
316        ),
317    );
318    m.insert(
319        "RUB",
320        CurrencyForms::new(
321            &["rublis", "rubliai", "rublių"],
322            &["kapeika", "kapeikos", "kapeikų"],
323        ),
324    );
325    m
326}
327
328/// Python's `right` inside `Num2Word_LT.to_currency`.
329///
330/// `parse_currency_parts` returns an `int` when `keep_precision` is off and a
331/// `Decimal` when it is on, and LT branches on `isinstance(right, Decimal)` to
332/// choose between `to_cardinal` and `to_cardinal_float`. Rust's
333/// `parse_currency_parts` always hands back a `BigDecimal`, so the Python type
334/// is carried explicitly rather than re-derived — collapsing the two would send
335/// whole cents down the float path and print "trisdešimt keturi kablelis nulis".
336enum Cents {
337    /// Python `int`.
338    Whole(BigInt),
339    /// Python `Decimal`, with a non-zero fractional part.
340    Fractional(BigDecimal),
341}
342
343/// Port of `utils.get_digits(n)` for an arbitrary-width `BigInt`.
344///
345/// ```python
346/// [int(x) for x in reversed(list(("%03d" % n)[-3:]))]
347/// ```
348///
349/// `%03d` zero-pads to width 3 and only the last three characters survive the
350/// slice, so for any magnitude this is "the last three digits, units-first" —
351/// `get_digits(1234)` is `[4, 3, 2]`, not a five-element list. That is why
352/// `pluralize(1234, ...)` agrees with `pluralize(234, ...)`.
353///
354/// The sign occupies part of the `%03d` field, so `"%03d" % -5` is `"-05"` and
355/// Python then dies on `int("-")` with a `ValueError`. Reproduced rather than
356/// smoothed over, though it is unreachable: every caller passes `abs(...)` or a
357/// subunit count. `n` is a full `BigInt` (not the `u32` chunk that
358/// [`splitbyx`] yields) because `to_currency` pluralizes `left`, which is
359/// unbounded.
360fn get_digits_big(n: &BigInt) -> Result<[usize; 3]> {
361    let digits = n.abs().to_string();
362    let s = if n.is_negative() {
363        format!("-{:0>2}", digits)
364    } else {
365        format!("{:0>3}", digits)
366    };
367    let chars: Vec<char> = s.chars().collect();
368    let tail = &chars[chars.len() - 3..];
369    let mut a = [0usize; 3];
370    for (k, c) in tail.iter().rev().enumerate() {
371        a[k] = c.to_digit(10).ok_or_else(|| {
372            N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
373        })? as usize;
374    }
375    Ok(a)
376}
377
378/// Reconstruct Python's `str(number)` for the value that reached
379/// `Num2Word_LT.to_cardinal`.
380///
381/// LT's float path is `str(number).split(".")`, **not** `base.float2tuple`, so
382/// the load-bearing f64 artefacts of the base path are irrelevant here: LT reads
383/// the decimal *digits*, so `2.675` yields "675" (the repr digits) and `1.005`
384/// yields "005" (two counted leading zeros, then 5). This helper produces the
385/// exact string Python's `str()` would, sign included, for both arms:
386///
387/// * **Float** — Python's `str(float)` is the shortest round-trip repr, always
388///   carrying a fractional part (`str(1.0) == "1.0"`). `precision` is the number
389///   of fractional digits in that repr (`abs(Decimal(repr).exponent)`), so
390///   `format!("{:.*}")` at that width reproduces the same digits — and, unlike
391///   Rust's bare `{}`, keeps the trailing `.0` and the leading `-`.
392/// * **Decimal** — Python's `str(Decimal)` preserves the exact scale, trailing
393///   zeros and all (`str(Decimal("1.10")) == "1.10"`). `precision` is that scale;
394///   `with_scale(precision)` re-expands so a `BigDecimal` that dropped trailing
395///   zeros is restored to the intended width before the digits are read off.
396/// `int(number)`'s truncation toward zero, for the finite float and Decimal
397/// arms. `None` only for a non-finite float (Python's `int()` raises there —
398/// see the callers for which exception).
399fn trunc_to_bigint(v: &FloatValue) -> Option<BigInt> {
400    match v {
401        FloatValue::Float { value, .. } => {
402            if !value.is_finite() {
403                return None;
404            }
405            BigInt::from_f64(value.trunc())
406        }
407        FloatValue::Decimal { value, .. } => {
408            // `with_scale(0)` truncates toward zero — exactly `int(Decimal)`.
409            Some(value.with_scale(0).as_bigint_and_exponent().0)
410        }
411    }
412}
413
414/// `int(n)`'s ValueError for the no-`"."` branch of `to_cardinal`, reached
415/// when `str(number)` came out in exponent form (`1e+16`, `1E+2`) or as
416/// `inf`/`nan`/`Infinity`. Message shape matches CPython's; the corpus checks
417/// exception types.
418fn int_value_error(literal: &str) -> N2WError {
419    N2WError::Value(format!(
420        "invalid literal for int() with base 10: '{}'",
421        literal
422    ))
423}
424
425/// `str(number)` for a float carrying no visible point: exponent form for
426/// finite values, `inf`/`nan` otherwise. Only feeds the ValueError message.
427fn float_no_point_str(f: f64) -> String {
428    if f.is_nan() {
429        return "nan".to_string();
430    }
431    if f.is_infinite() {
432        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
433    }
434    let s = format!("{:e}", f);
435    match s.split_once('e') {
436        Some((m, e)) if !e.starts_with('-') => format!("{}e+{:0>2}", m, e),
437        Some((m, e)) => format!("{}e-{:0>2}", m, &e[1..]),
438        None => s,
439    }
440}
441
442fn reconstructed_str(v: &FloatValue) -> String {
443    match v {
444        FloatValue::Float { value, precision } => {
445            format!("{:.*}", *precision as usize, value)
446        }
447        FloatValue::Decimal { value, precision } => {
448            let neg = value.is_negative();
449            let abs = value.abs();
450            let body = if *precision == 0 {
451                // Integer-valued Decimal (exponent 0): no ".", e.g. str("5").
452                abs.with_scale(0).as_bigint_and_exponent().0.to_string()
453            } else {
454                let sc = *precision as usize;
455                // coeff = abs(value) * 10**precision, as an integer.
456                let coeff = abs
457                    .with_scale(*precision as i64)
458                    .as_bigint_and_exponent()
459                    .0;
460                let digits = coeff.to_string();
461                // Left-pad to at least one integer digit before the point.
462                let padded = if digits.len() < sc + 1 {
463                    format!("{}{}", "0".repeat(sc + 1 - digits.len()), digits)
464                } else {
465                    digits
466                };
467                let cut = padded.len() - sc;
468                format!("{}.{}", &padded[..cut], &padded[cut..])
469            };
470            if neg {
471                format!("-{}", body)
472            } else {
473                body
474            }
475        }
476    }
477}
478
479pub struct LangLt {
480    currency_forms: HashMap<&'static str, CurrencyForms>,
481}
482
483impl Default for LangLt {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489impl LangLt {
490    pub fn new() -> Self {
491        LangLt {
492            // Built once here, never per call. `to_currency`/`to_cheque` only
493            // ever read this table; rebuilding it on each call is what made an
494            // earlier revision of this port slower than the Python it replaces.
495            currency_forms: build_currency_forms(),
496        }
497    }
498
499    /// Port of `Num2Word_LT.pluralize`.
500    ///
501    /// ```python
502    /// n1, n2, n3 = get_digits(n)
503    /// if n2 == 1 or n1 == 0 or n == 0: return forms[2]
504    /// elif n1 == 1:                    return forms[0]
505    /// else:                            return forms[1]
506    /// ```
507    /// `n` is always a 3-digit chunk here, so the `n == 0` test is redundant
508    /// (`n1 == 0` already covers it) — kept for structural fidelity.
509    fn pluralize(&self, n: u32, forms: &[&str; 3]) -> String {
510        let [n1, n2, _n3] = get_digits(n);
511        if n2 == 1 || n1 == 0 || n == 0 {
512            forms[2].to_string()
513        } else if n1 == 1 {
514            forms[0].to_string()
515        } else {
516            forms[1].to_string()
517        }
518    }
519
520    /// Port of `Num2Word_LT._int2word(n, feminine=False)`.
521    ///
522    /// # Bug 5: the `ONES_FEMININE` branch is unreachable
523    ///
524    /// Python guards it with:
525    ///
526    /// ```python
527    /// if (i == 1 or feminine and i == 0) and n < 1000:
528    /// ```
529    ///
530    /// `and` binds tighter than `or`, so this parses as
531    /// `((i == 1) or (feminine and (i == 0))) and (n < 1000)`. Both disjuncts
532    /// are then dead in every in-scope call:
533    ///
534    /// * `i == 1` means a thousands chunk exists, which forces `n >= 1000` and
535    ///   contradicts the `n < 1000` conjunct. So `i == 1` can never win.
536    /// * `feminine and i == 0` needs `feminine == true`, but no caller anywhere
537    ///   in the class passes it. `to_cardinal` calls `_int2word(int(n))` and
538    ///   leaves the default `feminine=False`; `to_currency` (now ported, see
539    ///   below) only ever reaches `_int2word` through `to_cardinal`, so it does
540    ///   not pass it either. Nothing else calls `_int2word`.
541    ///
542    /// Verified against the interpreter: `to_cardinal(21000)` ==
543    /// "dvidešimt vienas tūkstantis", using masculine "vienas" where the
544    /// feminine "viena" was evidently intended. The parameter and the branch are
545    /// kept so the control flow matches Python one-to-one; if a future caller
546    /// ever passes `feminine=true` with `n < 1000`, this reproduces what Python
547    /// would then do.
548    fn int2word(&self, n: &BigInt, feminine: bool) -> Result<String> {
549        if n.is_zero() {
550            return Ok(ZERO.to_string());
551        }
552
553        let mut words: Vec<String> = Vec::new();
554        let chunks = splitbyx(&n.to_string());
555        let mut i = chunks.len();
556        let thousand = BigInt::from(1000);
557
558        for x in chunks {
559            i -= 1;
560
561            if x == 0 {
562                continue;
563            }
564
565            let [n1, n2, n3] = get_digits(x);
566
567            if n3 > 0 {
568                words.push(ONES[n3].to_string());
569                if n3 > 1 {
570                    words.push(HUNDRED[1].to_string());
571                } else {
572                    words.push(HUNDRED[0].to_string());
573                }
574            }
575
576            if n2 > 1 {
577                words.push(TWENTIES[n2].to_string());
578            }
579
580            if n2 == 1 {
581                words.push(TENS[n1].to_string());
582            } else if n1 > 0 {
583                // See the bug-5 note above: this condition is never true.
584                if (i == 1 || (feminine && i == 0)) && *n < thousand {
585                    words.push(ONES_FEMININE[n1].to_string());
586                } else {
587                    words.push(ONES[n1].to_string());
588                }
589            }
590
591            if i > 0 {
592                // THOUSANDS[i] — KeyError past index 10 (bug 4).
593                let forms = thousands_at(i)?;
594                words.push(self.pluralize(x, forms));
595            }
596        }
597
598        Ok(words.join(" "))
599    }
600}
601
602impl Lang for LangLt {
603    /// This language's own `to_currency(currency=...)` default,
604    /// read from the live Python signature. Only 44 of 156 use EUR.
605    fn default_currency(&self) -> &str {
606        "EUR"
607    }
608
609    /// This language's own `to_currency(separator=...)` default,
610    /// read from the live Python signature. Base's is ",", but only
611    /// 36 of 149 languages actually use it — most default to " " or a
612    /// conjunction, so inheriting Base's comma silently corrupts them.
613    fn default_separator(&self) -> &str {
614        ""
615    }
616
617    fn negword(&self) -> &str {
618        NEGWORD
619    }
620
621    fn pointword(&self) -> &str {
622        "kablelis"
623    }
624
625    /// Port of `Num2Word_LT.to_cardinal`, integer path only.
626    ///
627    /// Python stringifies the input, swaps `","` for `"."`, then splits the
628    /// sign off with `parse_minus`. `str(int)` never contains a separator, so
629    /// integers always take the `else` branch:
630    /// `"%s%s" % (base_str, self._int2word(int(n)))`, where `base_str` is
631    /// `"minus "` (`negword.strip()` plus a space) for negatives and `""`
632    /// otherwise. The float branch (`pointword`, leading-zero padding) is out
633    /// of scope.
634    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
635        if value.is_negative() {
636            // base_str = "minus ", n = str(value)[1:] → int(n) == abs(value)
637            let words = self.int2word(&value.abs(), false)?;
638            Ok(format!("{} {}", NEGWORD, words))
639        } else {
640            self.int2word(value, false)
641        }
642    }
643
644    /// Port of `Num2Word_LT.to_ordinal`.
645    ///
646    /// ```python
647    /// try: num = int(number)
648    /// except (ValueError, TypeError): return str(number)
649    /// if num in ordinals: return ordinals[num]
650    /// cardinal = self.to_cardinal(num)
651    /// return cardinal + "as"
652    /// ```
653    ///
654    /// The `int()` guard cannot fire for integral input, so it is not modelled.
655    /// Everything outside the table gets the cardinal with "as" glued on
656    /// (bug 1), and a `KeyError` from `to_cardinal` propagates unchanged
657    /// (bug 4).
658    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
659        if let Some(word) = ordinal_lookup(value) {
660            return Ok(word.to_string());
661        }
662        Ok(format!("{}as", self.to_cardinal(value)?))
663    }
664
665    /// Port of the **float/Decimal arm of `Num2Word_LT.to_cardinal`**.
666    ///
667    /// ```python
668    /// n = str(number).replace(",", ".")
669    /// base_str, n = self.parse_minus(n)
670    /// if "." in n:
671    ///     left, right = n.split(".")
672    ///     leading_zero_count = len(right) - len(right.lstrip("0"))
673    ///     decimal_part = (ZERO[0] + " ") * leading_zero_count \
674    ///                    + self._int2word(int(right))
675    ///     return "%s%s %s %s" % (base_str, self._int2word(int(left)),
676    ///                            self.pointword, decimal_part)
677    /// else:
678    ///     return "%s%s" % (base_str, self._int2word(int(n)))
679    /// ```
680    ///
681    /// # Why this is NOT the base float path
682    ///
683    /// `Num2Word_LT` overrides `to_cardinal` and handles non-integers *inline
684    /// from the string* rather than through `base.float2tuple`. Two consequences
685    /// the base path would get wrong:
686    ///
687    /// * **No f64 heuristic.** LT reads the repr digits directly, so `2.675`
688    ///   becomes "šeši šimtai septyniasdešimt penki" (675 whole, from
689    ///   `int("675")`), not the base path's `.< 0.01`-rescued split. `1.005`
690    ///   becomes "nulis nulis penki": `right == "005"`, two counted leading
691    ///   zeros, then `int("005") == 5`.
692    /// * **Trailing-zero fidelity for Decimals.** `str(Decimal("1.10")) ==
693    ///   "1.10"`, so `right == "10"` and the fraction is "dešimt" (10), whereas a
694    ///   `float("1.10")` would collapse to "1.1". [`reconstructed_str`] restores
695    ///   the exact scale before the digits are read.
696    ///
697    /// `precision=` is accepted and ignored: `Num2Word_LT.to_cardinal` takes no
698    /// precision kwarg and never reads `self.precision`, so a caller-supplied
699    /// precision has no effect (verified against the interpreter:
700    /// `num2words(1.5, lang="lt", precision=5)` is still "vienas kablelis
701    /// penki"). `int2word` may still raise `KeyError` past 10^33 (bug 4), and a
702    /// leading-zero fractional digit string parses fine (`int("005") == 5`).
703    fn to_cardinal_float(
704        &self,
705        value: &FloatValue,
706        _precision_override: Option<u32>,
707    ) -> Result<String> {
708        let s = reconstructed_str(value);
709
710        // base_str, n = self.parse_minus(n) — negword.strip() == "minus".
711        let (base_str, n) = match s.strip_prefix('-') {
712            Some(rest) => (format!("{} ", NEGWORD), rest.to_string()),
713            None => (String::new(), s),
714        };
715
716        // int() over a digit substring: always valid here (str/Decimal reprs),
717        // but mapped to ValueError rather than panicking, mirroring int().
718        let to_int = |lit: &str| -> Result<BigInt> {
719            BigInt::parse_bytes(lit.as_bytes(), 10).ok_or_else(|| {
720                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", lit))
721            })
722        };
723
724        if let Some(dot) = n.find('.') {
725            let left = &n[..dot];
726            let right = &n[dot + 1..];
727            // leading_zero_count = len(right) - len(right.lstrip("0"))
728            let leading_zero_count = right.len() - right.trim_start_matches('0').len();
729            // decimal_part = (ZERO[0] + " ") * leading_zero_count
730            //                + self._int2word(int(right))
731            let decimal_part = format!(
732                "{}{}",
733                format!("{} ", ZERO).repeat(leading_zero_count),
734                self.int2word(&to_int(right)?, false)?
735            );
736            // "%s%s %s %s" % (base_str, _int2word(int(left)), pointword, dec)
737            Ok(format!(
738                "{}{} {} {}",
739                base_str,
740                self.int2word(&to_int(left)?, false)?,
741                POINTWORD,
742                decimal_part
743            ))
744        } else {
745            // "%s%s" % (base_str, self._int2word(int(n)))
746            Ok(format!("{}{}", base_str, self.int2word(&to_int(&n)?, false)?))
747        }
748    }
749
750    /// Full `to_cardinal(float/Decimal)` routing — Python's gate is
751    /// `"." in str(number)`, NOT the base default's `int(value) == value`:
752    ///
753    /// * a **visible point** (any finite float below 1e16, or a Decimal with
754    ///   positive scale) takes the fractional branch even for whole values —
755    ///   `5.0` -> "penki kablelis nulis nulis" (one "nulis" per leading zero
756    ///   of the fractional string plus `_int2word(0)`), `Decimal("5.00")` ->
757    ///   three "nulis".
758    /// * **no point** funnels the whole string into `int(n)`: plain digit
759    ///   Decimals reach the integer path, while exponent forms (`str(1e16) ==
760    ///   "1e+16"`, `str(Decimal("1E+2")) == "1E+2"`) and inf/nan raise
761    ///   **ValueError**, not the base default's OverflowError.
762    fn cardinal_float_entry(
763        &self,
764        value: &FloatValue,
765        precision_override: Option<u32>,
766    ) -> Result<String> {
767        if value.has_visible_point() {
768            return self.to_cardinal_float(value, precision_override);
769        }
770        match value {
771            FloatValue::Float { value, .. } => Err(int_value_error(&float_no_point_str(*value))),
772            FloatValue::Decimal { value, .. } => {
773                let s = crate::strnum::python_decimal_str(value);
774                match crate::strnum::python_int_parse(&s) {
775                    Some(i) => self.to_cardinal(&i),
776                    None => Err(int_value_error(&s)),
777                }
778            }
779        }
780    }
781
782    /// `to_ordinal(float/Decimal)`. Python:
783    ///
784    /// ```python
785    /// try: num = int(number)
786    /// except (ValueError, TypeError): return str(number)
787    /// ```
788    ///
789    /// `int()` **truncates**: `to_ordinal(2.5)` == `to_ordinal(2)` ==
790    /// "antras", `to_ordinal(-0.0)` == "nulisas" (int(-0.0) is 0, sign gone),
791    /// and `to_ordinal(1e16)` succeeds — int(float) never raises ValueError
792    /// for a finite value, so the ordinal table + "as" suffix run on the
793    /// truncated integer. `int(nan)` raises ValueError, which the except arm
794    /// converts to `str(number)` == "nan"; `int(inf)` raises OverflowError,
795    /// which is *not* in the except tuple and propagates.
796    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
797        if let FloatValue::Float { value: f, .. } = value {
798            if f.is_nan() {
799                return Ok("nan".to_string());
800            }
801            if f.is_infinite() {
802                return Err(N2WError::Overflow(
803                    "cannot convert float infinity to integer".into(),
804                ));
805            }
806        }
807        let num = trunc_to_bigint(value).expect("finite after the guards above");
808        self.to_ordinal(&num)
809    }
810
811    // year_float_entry is deliberately left at the trait default: Python's
812    // Num2Word_Base.to_year is `self.to_cardinal(value)` and LT does not
813    // override it, so the default's delegation to cardinal_float_entry —
814    // which now carries LT's own str-based routing — is exactly the port.
815
816    /// `converter.str_to_number` is Base's `Decimal(value)` (LT doesn't
817    /// override it), but `Decimal("Infinity")` then hits LT's `to_cardinal`,
818    /// where `str(number)` has no "." and `int("Infinity")` raises
819    /// **ValueError** — not the OverflowError the binding's generic Inf arm
820    /// would produce. NaN already maps to ValueError there.
821    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
822        match crate::strnum::python_decimal_parse(s)? {
823            ParsedNumber::Inf { negative } => Err(int_value_error(if negative {
824                "-Infinity"
825            } else {
826                "Infinity"
827            })),
828            other => Ok(other),
829        }
830    }
831
832    // to_ordinal_num is inherited unchanged from Num2Word_Base: the trait
833    // default (`value.to_string()`) already matches, so LT deliberately does
834    // not override it.
835
836    // ---- currency -------------------------------------------------------
837    //
838    // LT overrides exactly two things Python-side: `CURRENCY_FORMS` and
839    // `pluralize`, plus `to_currency` wholesale. Everything else on the
840    // currency path — `to_cheque`, `_money_verbose`, `_cents_verbose`,
841    // `_cents_terse`, `CURRENCY_PRECISION` (Base's empty dict, hence a flat
842    // divisor of 100) and `CURRENCY_ADJECTIVES` (also empty) — is inherited
843    // unchanged, so the trait defaults already mirror Python and are left
844    // alone. In particular `currency_precision` must NOT be overridden: LT has
845    // no 3-decimal or 0-decimal code, and `to_cheque` reads the default 100 to
846    // build its `NN/100` fraction.
847
848    fn lang_name(&self) -> &str {
849        "Num2Word_LT"
850    }
851
852    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
853        self.currency_forms.get(code)
854    }
855
856    /// Port of `Num2Word_LT.pluralize`.
857    ///
858    /// ```python
859    /// n1, n2, n3 = get_digits(n)
860    /// if n2 == 1 or n1 == 0 or n == 0: return forms[2]
861    /// elif n1 == 1:                    return forms[0]
862    /// else:                            return forms[1]
863    /// ```
864    ///
865    /// The Lithuanian rule keys off the last two digits: the teens and any
866    /// multiple of ten take the genitive plural (`forms[2]`, "eurų"), something
867    /// ending in a bare 1 takes the singular (`forms[0]`, "euras"), everything
868    /// else the nominative plural (`forms[1]`, "eurai"). Hence "vienas šimtas
869    /// eurų" (100 → n1 == 0) and "dvylika eurų" (12 → n2 == 1).
870    ///
871    /// The `n == 0` disjunct is dead: `get_digits(0)` already yields `n1 == 0`,
872    /// which fires first. Kept for structural fidelity.
873    ///
874    /// Python indexes the tuple directly, so a table entry with fewer than
875    /// three forms would raise IndexError. Every LT entry has three, so this is
876    /// unreachable — but it is mapped rather than panicking so the exception
877    /// type survives if the table ever changes.
878    ///
879    /// This mirrors the private [`LangLt::pluralize`] used by `int2word`, which
880    /// stays on `u32` chunks to keep that hot path free of `BigInt`
881    /// allocations. Reached here via `Lang::pluralize` (the inherent method
882    /// shadows the trait one for `self.pluralize(..)`).
883    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
884        let [n1, n2, _n3] = get_digits_big(n)?;
885        let idx = if n2 == 1 || n1 == 0 || n.is_zero() {
886            2
887        } else if n1 == 1 {
888            0
889        } else {
890            1
891        };
892        forms
893            .get(idx)
894            .cloned()
895            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
896    }
897
898    /// Port of `Num2Word_LT.to_currency`.
899    ///
900    /// LT *replaces* `Num2Word_Base.to_currency` rather than extending it, and
901    /// the replacement is not equivalent. Four divergences, all deliberate:
902    ///
903    /// 1. **The divisor is hardcoded to 100.** `parse_currency_parts` is called
904    ///    without `divisor=`, and `CURRENCY_PRECISION` is never consulted, so
905    ///    the 3-decimal / 0-decimal machinery in `default_to_currency` is
906    ///    bypassed entirely. Moot in practice — LT's table has no KWD/BHD/JPY
907    ///    entry, so those raise NotImplementedError long before a divisor could
908    ///    matter — but it is why this cannot delegate to `default_to_currency`.
909    /// 2. **The cents gate is `right > 0 or is_float`**, where `is_float` means
910    ///    only "the caller did not pass an `int`". Base instead computes
911    ///    `has_decimal = isinstance(val, float) or "." in str(val)`. The two
912    ///    agree on floats, but they split on `Decimal("5")`: Base prints "penki
913    ///    eurai", LT prints **"penki eurai, nulis centų"**. Verified against the
914    ///    interpreter. So `CurrencyValue::Decimal`'s `has_decimal` flag is
915    ///    ignored here *on purpose* — using it would be a silent behaviour
916    ///    change.
917    /// 3. **`adjective` is accepted and then ignored.** LT never reads
918    ///    `CURRENCY_ADJECTIVES` (Base's empty dict anyway), so
919    ///    `to_currency(2, adjective=True)` is just "du eurai".
920    /// 4. **`money_str` is `self.to_cardinal(left)`**, not
921    ///    `self._money_verbose(left, currency)`. Identical result — LT does not
922    ///    override `_money_verbose` — but kept literal.
923    ///
924    /// The separator is Python's `separator=""` default, resolved through
925    /// `default_separator()`. The empty string is not a sentinel: LT's own body
926    /// does `sep = separator if separator else ", "`, a truthiness test on the
927    /// value, which is ported as-is.
928    ///
929    /// # Preserved quirk: no space after an explicit separator
930    ///
931    /// The format string is `"%s%s %s%s%s %s"` — Base's is `"%s%s %s%s %s %s"`.
932    /// LT dropped the space because its default `", "` already carries one, so
933    /// any caller-supplied separator collides with the cents:
934    /// `to_currency(12.34, separator=" ir")` yields
935    /// `"dvylika eurų irtrisdešimt keturi centai"`. Verified against the
936    /// interpreter; reproduced byte for byte.
937    fn to_currency(
938        &self,
939        val: &CurrencyValue,
940        currency: &str,
941        cents: bool,
942        separator: Option<&str>,
943        _adjective: bool,
944    ) -> Result<String> {
945        // Trait hands us None when the caller omitted `separator=`; resolve it
946        // to LT's own default ("") before the ported body runs.
947        let separator = separator.unwrap_or(self.default_separator());
948
949        // `if isinstance(val, int): ... else: ...`
950        let (left, right, is_negative, is_float) = match val {
951            CurrencyValue::Int(v) => (v.abs(), Cents::Whole(BigInt::zero()), v.is_negative(), false),
952            CurrencyValue::Decimal { value, .. } => {
953                // has_fractional_cents = (Decimal(str(val)) * 100) % 1 != 0.
954                // Decimal's `%` keeps the dividend's sign, but the test is
955                // against 0 and Decimal("-0.00") == 0, so it is sign-agnostic —
956                // truncating toward zero reproduces it for negatives too.
957                let scaled = value * BigDecimal::from(100);
958                let has_fractional_cents = &scaled - scaled.with_scale(0) != BigDecimal::zero();
959
960                let (l, r, neg) = parse_currency_parts(val, false, has_fractional_cents, 100);
961                // keep_precision is exactly `isinstance(right, Decimal)`.
962                let r = if has_fractional_cents {
963                    Cents::Fractional(r)
964                } else {
965                    Cents::Whole(r.as_bigint_and_exponent().0)
966                };
967                (l, r, neg, true)
968            }
969        };
970
971        // `try: cr1, cr2 = self.CURRENCY_FORMS[currency] except KeyError: raise
972        // NotImplementedError(...)`. The KeyError is swallowed, so the variant
973        // is NotImplemented, not Key.
974        let forms = self.currency_forms.get(currency).ok_or_else(|| {
975            N2WError::NotImplemented(format!(
976                "Currency code \"{}\" not implemented for \"{}\"",
977                currency,
978                self.lang_name()
979            ))
980        })?;
981        let cr1 = &forms.unit;
982        let cr2 = &forms.subunit;
983
984        let minus_str = if is_negative {
985            format!("{} ", NEGWORD)
986        } else {
987            String::new()
988        };
989        let money_str = self.to_cardinal(&left)?;
990
991        // `if right > 0 or is_float:` — see divergence 2.
992        let right_is_positive = match &right {
993            Cents::Whole(r) => r.is_positive(),
994            Cents::Fractional(r) => r.is_positive(),
995        };
996        if !(right_is_positive || is_float) {
997            return Ok(format!(
998                "{}{} {}",
999                minus_str,
1000                money_str,
1001                Lang::pluralize(self, &left, cr1)?
1002            ));
1003        }
1004
1005        // `if right == 0: ... else: ...`
1006        let right_is_zero = match &right {
1007            Cents::Whole(r) => r.is_zero(),
1008            Cents::Fractional(r) => r.is_zero(),
1009        };
1010        let cents_str = if right_is_zero {
1011            if cents {
1012                self.to_cardinal(&BigInt::zero())?
1013            } else {
1014                "0".to_string()
1015            }
1016        } else {
1017            match &right {
1018                // `self.to_cardinal_float(float(right)) if cents else
1019                // str(float(right))` — fractional cents, e.g. 34.5.
1020                Cents::Fractional(r) => {
1021                    if cents {
1022                        // Base's to_cardinal_float via the float cast: exactly
1023                        // what `cardinal_from_decimal` defaults to. Left at the
1024                        // default per the port contract.
1025                        self.cardinal_from_decimal(r)?
1026                    } else {
1027                        let f = r.to_f64().ok_or_else(|| {
1028                            N2WError::Value(format!("cannot represent {} as f64", r))
1029                        })?;
1030                        format!("{}", f)
1031                    }
1032                }
1033                // `self.to_cardinal(right) if cents else str(right)`.
1034                Cents::Whole(r) => {
1035                    if cents {
1036                        self.to_cardinal(r)?
1037                    } else {
1038                        r.to_string()
1039                    }
1040                }
1041            }
1042        };
1043
1044        // `self.pluralize(right, cr2)` — `right` may still be a Decimal here.
1045        // `get_digits` renders it with `%03d`, which truncates, so the plural
1046        // rule only ever sees `int(right)`: pluralize(Decimal("34.5")) ==
1047        // pluralize(34) -> "centai". The `n == 0` disjunct cannot tell the two
1048        // apart either, because a truncated-to-zero Decimal also has n1 == 0
1049        // and takes the same `forms[2]` branch.
1050        let right_trunc = match &right {
1051            Cents::Whole(r) => r.clone(),
1052            Cents::Fractional(r) => r.with_scale(0).as_bigint_and_exponent().0,
1053        };
1054
1055        // `sep = separator if separator else ", "` — a truthiness test on the
1056        // ported default `""`, not a sentinel.
1057        let sep = if separator.is_empty() { ", " } else { separator };
1058
1059        // `"%s%s %s%s%s %s"` — note the missing space after `sep`.
1060        Ok(format!(
1061            "{}{} {}{}{} {}",
1062            minus_str,
1063            money_str,
1064            Lang::pluralize(self, &left, cr1)?,
1065            sep,
1066            cents_str,
1067            Lang::pluralize(self, &right_trunc, cr2)?
1068        ))
1069    }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use std::str::FromStr;
1076
1077    /// A Python `int` arg (corpus `arg` with no dot).
1078    fn int(s: &str) -> CurrencyValue {
1079        CurrencyValue::parse(s, true, false, false).unwrap()
1080    }
1081
1082    /// A Python `float` arg. `has_decimal` is always true for a float repr —
1083    /// and LT ignores the flag entirely (it keys on `isinstance(val, int)`),
1084    /// which `decimal_without_dot_still_shows_cents` pins.
1085    fn float(s: &str) -> CurrencyValue {
1086        CurrencyValue::parse(s, false, true, true).unwrap()
1087    }
1088
1089    fn cur(lt: &LangLt, code: &str, v: &CurrencyValue) -> Result<String> {
1090        lt.to_currency(v, code, true, None, false)
1091    }
1092
1093    /// Every succeeding `"lang": "lt", "to": "currency:*"` row of the frozen
1094    /// corpus, verbatim.
1095    #[test]
1096    fn currency_corpus_rows() {
1097        let lt = LangLt::new();
1098        let rows: Vec<(&str, CurrencyValue, &str)> = vec![
1099            ("EUR", int("0"), "nulis eurų"),
1100            ("EUR", int("1"), "vienas euras"),
1101            ("EUR", int("2"), "du eurai"),
1102            ("EUR", int("100"), "vienas šimtas eurų"),
1103            ("EUR", float("12.34"), "dvylika eurų, trisdešimt keturi centai"),
1104            ("EUR", float("0.01"), "nulis eurų, vienas centas"),
1105            ("EUR", float("1.0"), "vienas euras, nulis centų"),
1106            ("EUR", float("99.99"), "devyniasdešimt devyni eurai, devyniasdešimt devyni centai"),
1107            ("EUR", float("1234.56"), "vienas tūkstantis du šimtai trisdešimt keturi eurai, penkiasdešimt šeši centai"),
1108            ("EUR", float("-12.34"), "minus dvylika eurų, trisdešimt keturi centai"),
1109            ("EUR", int("1000000"), "vienas milijonas eurų"),
1110            ("EUR", float("0.5"), "nulis eurų, penkiasdešimt centų"),
1111            ("USD", int("0"), "nulis dolerių"),
1112            ("USD", int("1"), "vienas doleris"),
1113            ("USD", int("2"), "du doleriai"),
1114            ("USD", int("100"), "vienas šimtas dolerių"),
1115            ("USD", float("12.34"), "dvylika dolerių, trisdešimt keturi centai"),
1116            ("USD", float("0.01"), "nulis dolerių, vienas centas"),
1117            ("USD", float("1.0"), "vienas doleris, nulis centų"),
1118            ("USD", float("99.99"), "devyniasdešimt devyni doleriai, devyniasdešimt devyni centai"),
1119            ("USD", float("1234.56"), "vienas tūkstantis du šimtai trisdešimt keturi doleriai, penkiasdešimt šeši centai"),
1120            ("USD", float("-12.34"), "minus dvylika dolerių, trisdešimt keturi centai"),
1121            ("USD", int("1000000"), "vienas milijonas dolerių"),
1122            ("USD", float("0.5"), "nulis dolerių, penkiasdešimt centų"),
1123            ("GBP", int("0"), "nulis svarų sterlingų"),
1124            ("GBP", int("1"), "vienas svaras sterlingų"),
1125            ("GBP", int("2"), "du svarai sterlingų"),
1126            ("GBP", int("100"), "vienas šimtas svarų sterlingų"),
1127            ("GBP", float("12.34"), "dvylika svarų sterlingų, trisdešimt keturi pensai"),
1128            ("GBP", float("0.01"), "nulis svarų sterlingų, vienas pensas"),
1129            ("GBP", float("1.0"), "vienas svaras sterlingų, nulis pensų"),
1130            ("GBP", float("99.99"), "devyniasdešimt devyni svarai sterlingų, devyniasdešimt devyni pensai"),
1131            ("GBP", float("1234.56"), "vienas tūkstantis du šimtai trisdešimt keturi svarai sterlingų, penkiasdešimt šeši pensai"),
1132            ("GBP", float("-12.34"), "minus dvylika svarų sterlingų, trisdešimt keturi pensai"),
1133            ("GBP", int("1000000"), "vienas milijonas svarų sterlingų"),
1134            ("GBP", float("0.5"), "nulis svarų sterlingų, penkiasdešimt pensų"),
1135        ];
1136        for (code, val, want) in rows {
1137            assert_eq!(cur(&lt, code, &val).unwrap(), want, "currency:{} {:?}", code, val);
1138        }
1139    }
1140
1141    /// The corpus rows LT's table has no entry for. All 72 are
1142    /// NotImplementedError — including JPY/KWD/BHD, which never reach the
1143    /// 0-/3-decimal paths because the lookup fails first.
1144    #[test]
1145    fn currency_corpus_notimplemented_rows() {
1146        let lt = LangLt::new();
1147        let vals = [
1148            int("0"), int("1"), int("2"), int("100"), float("12.34"), float("0.01"),
1149            float("1.0"), float("99.99"), float("1234.56"), float("-12.34"),
1150            int("1000000"), float("0.5"),
1151        ];
1152        for code in ["JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1153            for val in &vals {
1154                match cur(&lt, code, val) {
1155                    Err(N2WError::NotImplemented(m)) => assert_eq!(
1156                        m,
1157                        format!("Currency code \"{}\" not implemented for \"Num2Word_LT\"", code)
1158                    ),
1159                    other => panic!("currency:{} {:?} -> {:?}", code, val, other),
1160                }
1161            }
1162        }
1163    }
1164
1165    /// Every `"lang": "lt", "to": "cheque:*"` row of the frozen corpus.
1166    /// `to_cheque` is inherited from `Num2Word_Base`; these pin that the
1167    /// default is in fact the right behaviour for LT — note the unit is the
1168    /// *third* (genitive plural) form, since Base takes `cr1[-1]`.
1169    #[test]
1170    fn cheque_corpus_rows() {
1171        let lt = LangLt::new();
1172        let v = BigDecimal::from_str("1234.56").unwrap();
1173        for (code, want) in [
1174            ("EUR", "VIENAS TŪKSTANTIS DU ŠIMTAI TRISDEŠIMT KETURI AND 56/100 EURŲ"),
1175            ("USD", "VIENAS TŪKSTANTIS DU ŠIMTAI TRISDEŠIMT KETURI AND 56/100 DOLERIŲ"),
1176            ("GBP", "VIENAS TŪKSTANTIS DU ŠIMTAI TRISDEŠIMT KETURI AND 56/100 SVARŲ STERLINGŲ"),
1177        ] {
1178            assert_eq!(lt.to_cheque(&v, code).unwrap(), want, "cheque:{}", code);
1179        }
1180        for code in ["JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1181            match lt.to_cheque(&v, code) {
1182                Err(N2WError::NotImplemented(m)) => assert_eq!(
1183                    m,
1184                    format!("Currency code \"{}\" not implemented for \"Num2Word_LT\"", code)
1185                ),
1186                other => panic!("cheque:{} -> {:?}", code, other),
1187            }
1188        }
1189    }
1190
1191    /// The three codes the corpus never exercises. Values from the live
1192    /// interpreter.
1193    #[test]
1194    fn currency_codes_absent_from_corpus() {
1195        let lt = LangLt::new();
1196        let rows: Vec<(&str, CurrencyValue, &str)> = vec![
1197            ("LTL", int("1"), "vienas litas"),
1198            ("LTL", int("2"), "du litai"),
1199            ("LTL", int("0"), "nulis litų"),
1200            ("LTL", float("1.0"), "vienas litas, nulis centų"),
1201            ("LTL", float("12.34"), "dvylika litų, trisdešimt keturi centai"),
1202            ("PLN", int("1"), "vienas zlotas"),
1203            ("PLN", int("2"), "du zlotai"),
1204            ("PLN", int("0"), "nulis zlotų"),
1205            ("PLN", float("1.0"), "vienas zlotas, nulis grašių"),
1206            ("PLN", float("12.34"), "dvylika zlotų, trisdešimt keturi grašiai"),
1207            ("RUB", int("1"), "vienas rublis"),
1208            ("RUB", int("2"), "du rubliai"),
1209            ("RUB", int("0"), "nulis rublių"),
1210            ("RUB", float("1.0"), "vienas rublis, nulis kapeikų"),
1211            ("RUB", float("12.34"), "dvylika rublių, trisdešimt keturi kapeikos"),
1212        ];
1213        for (code, val, want) in rows {
1214            assert_eq!(cur(&lt, code, &val).unwrap(), want, "currency:{} {:?}", code, val);
1215        }
1216    }
1217
1218    /// Divergence 2: LT's cents gate is `right > 0 or is_float`, where
1219    /// `is_float` means only "not a Python int" — so `Decimal("5")`, whose
1220    /// `has_decimal` is false, still prints cents. `Num2Word_Base` would say
1221    /// "penki eurai". Both values from the live interpreter.
1222    #[test]
1223    fn decimal_without_dot_still_shows_cents() {
1224        let lt = LangLt::new();
1225        let d5 = CurrencyValue::parse("5", false, false, false).unwrap();
1226        assert_eq!(cur(&lt, "EUR", &d5).unwrap(), "penki eurai, nulis centų");
1227        // ...while a true int takes the no-cents branch.
1228        assert_eq!(cur(&lt, "EUR", &int("5")).unwrap(), "penki eurai");
1229    }
1230
1231    /// Fractional cents: `right` stays a `Decimal` and goes through
1232    /// `to_cardinal_float`, while `pluralize` only ever sees `int(right)`.
1233    /// Values from the live interpreter.
1234    #[test]
1235    fn fractional_cents() {
1236        let lt = LangLt::new();
1237        // right == Decimal("34.500") -> "trisdešimt keturi kablelis penki",
1238        // pluralized as 34 -> "centai".
1239        assert_eq!(
1240            cur(&lt, "EUR", &float("12.345")).unwrap(),
1241            "dvylika eurų, trisdešimt keturi kablelis penki centai"
1242        );
1243        // right == Decimal("1.100") -> pluralized as 1 -> "centas" (singular).
1244        assert_eq!(
1245            cur(&lt, "EUR", &float("1.011")).unwrap(),
1246            "vienas euras, vienas kablelis vienas centas"
1247        );
1248        // right == Decimal("0.500") -> truncates to 0 -> n1 == 0 -> "centų".
1249        assert_eq!(
1250            cur(&lt, "EUR", &float("0.005")).unwrap(),
1251            "nulis eurų, nulis kablelis penki centų"
1252        );
1253    }
1254
1255    /// `cents=False` takes the terse branch — `str(right)`, not
1256    /// `_cents_terse`, so there is no zero padding ("0", never "00").
1257    #[test]
1258    fn cents_false() {
1259        let lt = LangLt::new();
1260        let terse = |v: &CurrencyValue| lt.to_currency(v, "EUR", false, None, false).unwrap();
1261        assert_eq!(terse(&float("12.34")), "dvylika eurų, 34 centai");
1262        assert_eq!(terse(&float("1.0")), "vienas euras, 0 centų");
1263        // str(float(Decimal("34.500"))) == "34.5"
1264        assert_eq!(terse(&float("12.345")), "dvylika eurų, 34.5 centai");
1265    }
1266
1267    /// Preserved quirk: LT's format string has no space after the separator,
1268    /// because its default `", "` already carries one. Verified against the
1269    /// interpreter.
1270    #[test]
1271    fn explicit_separator_has_no_trailing_space() {
1272        let lt = LangLt::new();
1273        assert_eq!(
1274            lt.to_currency(&float("12.34"), "EUR", true, Some(" ir"), false).unwrap(),
1275            "dvylika eurų irtrisdešimt keturi centai"
1276        );
1277        assert_eq!(
1278            lt.to_currency(&float("12.34"), "EUR", true, Some(","), false).unwrap(),
1279            "dvylika eurų,trisdešimt keturi centai"
1280        );
1281        // separator="" is Python's own default, and its truthiness test
1282        // restores ", " — it is a value, not a sentinel.
1283        assert_eq!(
1284            lt.to_currency(&float("12.34"), "EUR", true, Some(""), false).unwrap(),
1285            "dvylika eurų, trisdešimt keturi centai"
1286        );
1287    }
1288
1289    /// Divergence 3: `adjective` is accepted and ignored (LT never reads
1290    /// CURRENCY_ADJECTIVES, which is Base's empty dict).
1291    #[test]
1292    fn adjective_is_ignored() {
1293        let lt = LangLt::new();
1294        assert_eq!(
1295            lt.to_currency(&float("12.34"), "EUR", true, None, true).unwrap(),
1296            "dvylika eurų, trisdešimt keturi centai"
1297        );
1298        assert_eq!(lt.to_currency(&int("2"), "EUR", true, None, true).unwrap(), "du eurai");
1299    }
1300
1301    /// `get_digits` keeps only the last three digits, so the plural rule is
1302    /// driven by them alone regardless of magnitude.
1303    #[test]
1304    fn pluralize_reads_last_three_digits() {
1305        let lt = LangLt::new();
1306        let forms: Vec<String> = ["euras", "eurai", "eurų"].iter().map(|s| s.to_string()).collect();
1307        let p = |n: i64| Lang::pluralize(&lt, &BigInt::from(n), &forms).unwrap();
1308        assert_eq!(p(1), "euras");
1309        assert_eq!(p(21), "euras"); // n1 == 1
1310        assert_eq!(p(11), "eurų"); // n2 == 1 (teens)
1311        assert_eq!(p(12), "eurų");
1312        assert_eq!(p(2), "eurai");
1313        assert_eq!(p(1234), "eurai"); // reads "234"
1314        assert_eq!(p(0), "eurų");
1315        assert_eq!(p(100), "eurų"); // n1 == 0
1316        assert_eq!(p(1000000), "eurų");
1317        // Beyond u32/u64 — pluralize must not narrow the value.
1318        assert_eq!(
1319            Lang::pluralize(&lt, &BigInt::from_str("100000000000000000000001").unwrap(), &forms).unwrap(),
1320            "euras"
1321        );
1322    }
1323
1324    /// Bug reproduction, not endorsement: `"%03d" % -5` is "-05", and Python
1325    /// then dies on `int("-")` with a ValueError. Unreachable from every real
1326    /// caller (all pass abs()), pinned so the variant does not drift.
1327    #[test]
1328    fn pluralize_negative_is_a_value_error() {
1329        let lt = LangLt::new();
1330        let forms: Vec<String> = ["euras", "eurai", "eurų"].iter().map(|s| s.to_string()).collect();
1331        assert!(matches!(
1332            Lang::pluralize(&lt, &BigInt::from(-5), &forms),
1333            Err(N2WError::Value(_))
1334        ));
1335    }
1336
1337    /// Build a `float`-arm value the way the py binding does: raw f64 plus the
1338    /// repr-derived precision `abs(Decimal(repr(v)).as_tuple().exponent)`.
1339    fn flt(value: f64, precision: u32) -> FloatValue {
1340        FloatValue::Float { value, precision }
1341    }
1342
1343    /// Build a `Decimal`-arm value: BigDecimal parsed from `str(Decimal)`, with
1344    /// precision == the scale (`abs(exponent)`).
1345    fn dec(s: &str, precision: u32) -> FloatValue {
1346        FloatValue::Decimal {
1347            value: BigDecimal::from_str(s).unwrap(),
1348            precision,
1349        }
1350    }
1351
1352    /// Every `"lang": "lt", "to": "cardinal"` corpus row whose `arg` has a dot
1353    /// (float input), verbatim.
1354    #[test]
1355    fn cardinal_float_corpus_rows() {
1356        let lt = LangLt::new();
1357        let rows: Vec<(FloatValue, &str)> = vec![
1358            (flt(0.0, 1), "nulis kablelis nulis nulis"),
1359            (flt(0.5, 1), "nulis kablelis penki"),
1360            (flt(1.0, 1), "vienas kablelis nulis nulis"),
1361            (flt(1.5, 1), "vienas kablelis penki"),
1362            (flt(2.25, 2), "du kablelis dvidešimt penki"),
1363            (flt(3.14, 2), "trys kablelis keturiolika"),
1364            (flt(0.01, 2), "nulis kablelis nulis vienas"),
1365            (flt(0.1, 1), "nulis kablelis vienas"),
1366            (flt(0.99, 2), "nulis kablelis devyniasdešimt devyni"),
1367            (flt(1.01, 2), "vienas kablelis nulis vienas"),
1368            (flt(12.34, 2), "dvylika kablelis trisdešimt keturi"),
1369            (flt(99.99, 2), "devyniasdešimt devyni kablelis devyniasdešimt devyni"),
1370            (flt(100.5, 1), "vienas šimtas kablelis penki"),
1371            (flt(1234.56, 2), "vienas tūkstantis du šimtai trisdešimt keturi kablelis penkiasdešimt šeši"),
1372            (flt(-0.5, 1), "minus nulis kablelis penki"),
1373            (flt(-1.5, 1), "minus vienas kablelis penki"),
1374            (flt(-12.34, 2), "minus dvylika kablelis trisdešimt keturi"),
1375            // f64-artefact cases: LT reads the repr digits, so no float2tuple
1376            // rescue is involved — "005" -> 5, "675" -> 675.
1377            (flt(1.005, 3), "vienas kablelis nulis nulis penki"),
1378            (flt(2.675, 3), "du kablelis šeši šimtai septyniasdešimt penki"),
1379        ];
1380        for (v, want) in rows {
1381            assert_eq!(lt.to_cardinal_float(&v, None).unwrap(), want, "cardinal {:?}", v);
1382        }
1383    }
1384
1385    /// Every `"lang": "lt", "to": "cardinal_dec"` corpus row (Decimal input),
1386    /// verbatim — the #603 precision path a float cast would round away.
1387    #[test]
1388    fn cardinal_decimal_corpus_rows() {
1389        let lt = LangLt::new();
1390        let rows: Vec<(FloatValue, &str)> = vec![
1391            (dec("0.01", 2), "nulis kablelis nulis vienas"),
1392            // Trailing zero preserved: right == "10" -> "dešimt", not "1.1".
1393            (dec("1.10", 2), "vienas kablelis dešimt"),
1394            (dec("12.345", 3), "dvylika kablelis trys šimtai keturiasdešimt penki"),
1395            (dec("98746251323029.99", 2),
1396             "devyniasdešimt aštuoni trilijonai septyni šimtai keturiasdešimt šeši \
1397              milijardai du šimtai penkiasdešimt vienas milijonas trys šimtai \
1398              dvidešimt trys tūkstančiai dvidešimt devyni kablelis devyniasdešimt devyni"),
1399            (dec("0.001", 3), "nulis kablelis nulis nulis vienas"),
1400        ];
1401        for (v, want) in rows {
1402            assert_eq!(lt.to_cardinal_float(&v, None).unwrap(), want, "cardinal_dec {:?}", v);
1403        }
1404    }
1405
1406    /// Decimal edge cases beyond the corpus, from the live interpreter:
1407    /// an integer-valued Decimal (no dot), a scaled zero, and trailing zeros
1408    /// that make a three-digit fraction.
1409    #[test]
1410    fn cardinal_decimal_edges() {
1411        let lt = LangLt::new();
1412        // str(Decimal("5")) == "5" -> no "." -> integer branch.
1413        assert_eq!(lt.to_cardinal_float(&dec("5", 0), None).unwrap(), "penki");
1414        // str(Decimal("5.00")) == "5.00" -> right "00" -> two leading zeros + 0.
1415        assert_eq!(
1416            lt.to_cardinal_float(&dec("5.00", 2), None).unwrap(),
1417            "penki kablelis nulis nulis nulis"
1418        );
1419        // str(Decimal("1.100")) == "1.100" -> right "100" -> int("100") == 100.
1420        assert_eq!(
1421            lt.to_cardinal_float(&dec("1.100", 3), None).unwrap(),
1422            "vienas kablelis vienas šimtas"
1423        );
1424        // Negative integer-valued Decimal keeps the minus.
1425        assert_eq!(lt.to_cardinal_float(&dec("-1.5", 1), None).unwrap(), "minus vienas kablelis penki");
1426    }
1427
1428    /// `precision=` is ignored by LT (its to_cardinal never reads
1429    /// self.precision), verified against the interpreter.
1430    #[test]
1431    fn precision_override_is_ignored() {
1432        let lt = LangLt::new();
1433        for p in [Some(0u32), Some(1), Some(5), None] {
1434            assert_eq!(
1435                lt.to_cardinal_float(&flt(1.5, 1), p).unwrap(),
1436                "vienas kablelis penki",
1437                "precision override {:?}", p
1438            );
1439        }
1440        // 2.675 with precision=1 is still the full repr-digit split.
1441        assert_eq!(
1442            lt.to_cardinal_float(&flt(2.675, 3), Some(1)).unwrap(),
1443            "du kablelis šeši šimtai septyniasdešimt penki"
1444        );
1445    }
1446
1447    /// Non-corpus floats with messy shortest-round-trip reprs, from the live
1448    /// interpreter. Confirms `format!("{:.*}")` reproduces Python's `str(float)`
1449    /// digit-for-digit even at repr's full width — 0.1+0.2 spills 17 fractional
1450    /// digits, whose `int(...)` is a 17-digit whole number ("...kvadrilijonų...").
1451    #[test]
1452    fn cardinal_float_messy_reprs() {
1453        let lt = LangLt::new();
1454        let rows: Vec<(FloatValue, &str)> = vec![
1455            (flt(10.0, 1), "dešimt kablelis nulis nulis"),
1456            (flt(0.25, 2), "nulis kablelis dvidešimt penki"),
1457            (flt(123.456, 3), "vienas šimtas dvidešimt trys kablelis keturi šimtai penkiasdešimt šeši"),
1458            (flt(1000000.001, 3), "vienas milijonas kablelis nulis nulis vienas"),
1459            (flt(3.0, 1), "trys kablelis nulis nulis"),
1460            (flt(0.0001, 4), "nulis kablelis nulis nulis nulis vienas"),
1461            (flt(-0.01, 2), "minus nulis kablelis nulis vienas"),
1462            // 0.1 + 0.2 == 0.30000000000000004 in IEEE-754 (precision 17).
1463            (flt(0.1 + 0.2, 17), "nulis kablelis trisdešimt kvadrilijonų keturi"),
1464        ];
1465        for (v, want) in rows {
1466            assert_eq!(lt.to_cardinal_float(&v, None).unwrap(), want, "cardinal {:?}", v);
1467        }
1468    }
1469}