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