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(<, 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(<, 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(<, 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(<, "EUR", &d5).unwrap(), "penki eurai, nulis centų");
1227 // ...while a true int takes the no-cents branch.
1228 assert_eq!(cur(<, "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(<, "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(<, "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(<, "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(<, &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(<, &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(<, &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}