num2words2_core/lang_ln.rs
1//! Port of `lang_LN.py` (Lingala).
2//!
3//! Shape: **self-contained**. `Num2Word_LN` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
5//! `Num2Word_Base.__init__` never builds `self.cards` and never sets
6//! `MAXVAL`. `to_cardinal` is overridden outright and drives a plain
7//! recursive `_int_to_word`. Consequently `cards`/`maxval`/`merge` stay at
8//! their trait defaults here, and there is **no overflow check** — see the
9//! digit fallback below for what happens instead.
10//!
11//! Inherited from `Num2Word_Base` and left alone by LN:
12//! * nothing in the four in-scope modes — LN overrides `to_cardinal`,
13//! `to_ordinal` and `to_ordinal_num`, and its `to_year(val, longval=True)`
14//! ignores `longval` and just forwards to `to_cardinal`. The trait's
15//! default `to_year` already delegates through `&self`, so it picks up the
16//! `to_cardinal` override below and matches Python exactly; it is left
17//! unimplemented here rather than restated.
18//!
19//! LN carries **no cross-call mutable state** — no `_pending_ordinal`-style
20//! handshake. `str_to_number` keeps Base's `Decimal(value)` semantics except
21//! that an Infinity parse punts to the Python fallback (see the hook), so the
22//! dispatcher's Rust fast path for plain ints is safe.
23//!
24//! # Faithfully reproduced Python behaviour
25//!
26//! This is a port, not a rewrite. The following look wrong but are exactly
27//! what Python emits, and are confirmed by the frozen corpus:
28//!
29//! 1. **The digit fallback.** `_int_to_word` handles the number only up to
30//! `< 1000000000`; its final `else` is `return str(number)`. So Lingala
31//! silently stops wording at 10^9 and hands back bare digits:
32//! `to_cardinal(10**9)` == "1000000000" (not an `OverflowError`), and
33//! `to_ordinal(10**9)` == "1000000000-e". This is why the value must stay a
34//! `BigInt` — the fallback is reached for arbitrarily large input
35//! (corpus covers 10^21) and `to_string()` must render it exactly.
36//! 2. **No scale word above "milio".** There is no billion/trillion entry at
37//! all; 10^9 is simply where wording stops (see 1).
38//! 3. **`to_ordinal` is cardinal + "-e", unconditionally.** No negative guard
39//! (Python's `errmsg_negord` is never consulted), so `to_ordinal(-1)` ==
40//! "minus moko-e", and the suffix lands on the *last word* with no space:
41//! `to_ordinal(11)` == "zómi moko-e". It also glues onto the digit
42//! fallback, hence "1000000000-e".
43//! 4. **`to_ordinal_num` ignores the language entirely** and is `str(number)
44//! + "."`, so `to_ordinal_num(-1)` == "-1." and `to_ordinal_num(0)` == "0.".
45//! 5. **`_int_to_word(0)` is `self.ones[0] if self.ones[0] else "zero"`.**
46//! `ones[0]` is `""` (falsy), so the branch is dead and the answer is
47//! always "zero" — never the empty string. Mirrored as a constant below.
48//!
49//! # Unreachable-but-preserved code
50//!
51//! `_int_to_word`'s `if number < 0` arm cannot fire from any of the four
52//! in-scope modes: `to_cardinal` strips the "-" off the *string* before
53//! calling `int()`, so `_int_to_word` only ever sees a non-negative value.
54//! It is reproduced anyway ([`int_to_word`]) so the function matches its
55//! Python counterpart line for line.
56//!
57//! # The currency surface
58//!
59//! `Num2Word_LN` declares its own `CURRENCY_FORMS` (CDF/USD/EUR) as a class
60//! attribute and inherits `CURRENCY_ADJECTIVES = {}` / `CURRENCY_PRECISION =
61//! {}` from `Num2Word_Base`. Verified against the live interpreter: the MRO is
62//! exactly `Num2Word_LN -> Num2Word_Base -> object`, so LN never touches the
63//! `Num2Word_EUR` dict that `Num2Word_EN.__init__` mutates in place, and none
64//! of English's ~24 extra codes leak in. `CURRENCY_PRECISION` being empty means
65//! **every** code runs at the default divisor 100 — LN has no 3-decimal
66//! (KWD/BHD) or 0-decimal (JPY) path at all, and the corpus confirms it:
67//! `currency:JPY` of `12.34` is "... ntuku mísáto mínei santimi", i.e. 34
68//! subunits, not a rounded whole.
69//!
70//! `to_currency` is overridden outright and ignores `base.to_currency`
71//! entirely — no `parse_currency_parts`, no `divisor`, no `pluralize`, no
72//! `prefix_currency`. `to_cheque` is **not** overridden, so it comes from
73//! `Num2Word_Base` (the trait default reproduces it) and reaches back into
74//! LN's `to_cardinal` through the default `_money_verbose`.
75//!
76//! ## Further faithfully reproduced Python behaviour
77//!
78//! 6. **Unknown currency codes never raise from `to_currency`.** The lookup is
79//! `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
80//! — a miss silently falls back to the *first inserted* entry, `CDF`. So
81//! `currency:GBP` prints "faranga"/"santimi" rather than raising. The
82//! inherited `to_cheque` subscripts `CURRENCY_FORMS[currency]` and converts
83//! the `KeyError` into `NotImplementedError`, so `cheque:GBP` *does* raise.
84//! Both halves are in the corpus and the split is deliberate — see the note
85//! on [`LangLn::currency_forms`].
86//! 7. **Cents are sliced out of the decimal string, not computed.**
87//! `int(parts[1][:2].ljust(2, "0"))` truncates past two digits and pads
88//! short ones, so `0.5` is 50 subunits and `0.01` is 1. There is no
89//! rounding: `1.567` yields 56 subunits, not 57.
90//! 8. **A float with zero cents drops the cents segment.** `if cents and
91//! right:` tests an `int`, and `1.0` gives `right == 0`, which is falsy. So
92//! `currency:EUR` of `1.0` is "moko euro" — the same output as the `int` 1,
93//! but arrived at through the string `"1.0"` rather than through Python's
94//! `isinstance(val, int)` branch, which LN never consults.
95//! 9. **`cents=False` drops the segment outright.** `Num2Word_Base` would fall
96//! back to `_cents_terse` and print "34"; LN has no `else`, so the cents
97//! simply vanish.
98//! 10. **Zero takes the plural.** `cr1[1] if left != 1 else cr1[0]` keys off
99//! `!= 1`, so `0` renders "zero euros".
100
101use crate::base::{Lang, N2WError, Result};
102use crate::currency::{CurrencyForms, CurrencyValue};
103use crate::floatpath::FloatValue;
104use crate::strnum::{python_decimal_parse, ParsedNumber};
105use bigdecimal::BigDecimal;
106use num_bigint::BigInt;
107use num_integer::Integer;
108use num_traits::{One, Signed, ToPrimitive, Zero};
109use std::collections::HashMap;
110use std::str::FromStr;
111
112/// `self.negword` — note the trailing space; `to_cardinal` concatenates it
113/// directly onto the worded magnitude and `.strip()`s the result.
114const NEGWORD: &str = "minus ";
115
116/// `self.ones`. Index 0 is `""` in Python and is only ever reached through
117/// the dead `_int_to_word(0)` branch documented above.
118const ONES: [&str; 10] = [
119 "", "moko", "míbalé", "mísáto", "mínei", "mítáno", "motóbá", "sambo", "mwambe", "libwá",
120];
121
122/// `self.tens`. Index 0 is `""` and is unreachable (the `< 100` arm is only
123/// entered for `number >= 10`, so `tens_val >= 1`).
124const TENS: [&str; 10] = [
125 "",
126 "zómi",
127 "ntuku míbalé",
128 "ntuku mísáto",
129 "ntuku mínei",
130 "ntuku mítáno",
131 "ntuku motóbá",
132 "ntuku sambo",
133 "ntuku mwambe",
134 "ntuku libwá",
135];
136
137const HUNDRED: &str = "nkama";
138const THOUSAND: &str = "nkóto";
139const MILLION: &str = "milio";
140
141/// `self.pointword`, interpolated raw between the integer part and the digits
142/// on the float path: `... + " " + self.pointword + " "`. LN never calls
143/// `self.title()`, so it is emitted verbatim (mirrors [`LangLn::pointword`]).
144const POINTWORD: &str = "point";
145
146/// `Num2Word_LN.to_currency`'s own default `separator=" "`, confirmed against
147/// the interpreter: `Num2Word_LN.to_currency.__defaults__` is
148/// `("CDF", True, " ", False)`.
149///
150/// Unlike `Num2Word_Base`, whose format string writes `separator` *followed by
151/// a literal space*, LN writes `result += separator + cents_str + ...`. The
152/// separator is therefore the entire gap, and LN's `" "` is what keeps the
153/// default output comma-free ("moko euro moko cent").
154const DEFAULT_SEPARATOR: &str = " ";
155
156/// `Num2Word_Base.to_currency`'s default `separator=","`.
157///
158/// Both callers of the Rust core — `__init__.py`'s fast path and
159/// `bench/diff_test.py` — send `kwargs.get("separator", ",")`, i.e. *base's*
160/// default rather than the per-language one. The trait signature has no way to
161/// express a default argument, so "caller said nothing" and "caller explicitly
162/// asked for `,`" arrive identically. `to_currency` reads this value as the
163/// former and substitutes [`DEFAULT_SEPARATOR`]; see the note on
164/// [`LangLn::to_currency`]. `lang_br.rs` and `lang_es.rs` resolve the same
165/// conflict the same way.
166const BASE_DEFAULT_SEPARATOR: &str = ",";
167
168/// The value `_int_to_word(0)` returns. Python writes
169/// `self.ones[0] if self.ones[0] else "zero"`; `ones[0]` is `""`, so the
170/// conditional always takes the `else`.
171const ZERO_WORD: &str = "zero";
172
173/// Narrow a `BigInt` to a table index.
174///
175/// Every call site has already bounded the value by an explicit `<`
176/// comparison (`< 10`, or a quotient of a value `< 1000`), so the value is in
177/// `0..=9` and the conversion cannot fail. `unwrap_or(0)` keeps the function
178/// total without masking a real bug: a hypothetical out-of-range value would
179/// hit `ONES[0]`/`TENS[0]` == `""`, the same thing Python's list would return
180/// for index 0.
181fn idx(n: &BigInt) -> usize {
182 n.to_usize().unwrap_or(0)
183}
184
185/// Python's `Num2Word_LN._int_to_word`.
186///
187/// Recursive, and deliberately stops at 10^9 — past that it returns the
188/// decimal digits (bug 1 in the module docs).
189fn int_to_word(n: &BigInt) -> String {
190 if n.is_zero() {
191 return ZERO_WORD.to_string();
192 }
193
194 // Unreachable from the four in-scope modes (see module docs); preserved
195 // to mirror the Python function exactly.
196 if n.is_negative() {
197 return format!("{}{}", NEGWORD, int_to_word(&n.abs()));
198 }
199
200 let ten = BigInt::from(10u32);
201 let hundred = BigInt::from(100u32);
202 let thousand = BigInt::from(1000u32);
203 let million = BigInt::from(1_000_000u32);
204 let billion = BigInt::from(1_000_000_000u32);
205
206 if n < &ten {
207 return ONES[idx(n)].to_string();
208 }
209
210 if n < &hundred {
211 // Python: number // 10, number % 10. Operands are positive here, so
212 // floor- and trunc-division agree.
213 let (tens_val, ones_val) = n.div_rem(&ten);
214 if ones_val.is_zero() {
215 return TENS[idx(&tens_val)].to_string();
216 }
217 return format!("{} {}", TENS[idx(&tens_val)], ONES[idx(&ones_val)]);
218 }
219
220 if n < &thousand {
221 // Note: hundreds uses `self.ones[hundreds_val]` directly — not a
222 // recursive call — so 100 is "moko nkama".
223 let (hundreds_val, remainder) = n.div_rem(&hundred);
224 let mut result = format!("{} {}", ONES[idx(&hundreds_val)], HUNDRED);
225 if !remainder.is_zero() {
226 result.push(' ');
227 result.push_str(&int_to_word(&remainder));
228 }
229 return result;
230 }
231
232 if n < &million {
233 let (thousands_val, remainder) = n.div_rem(&thousand);
234 let mut result = format!("{} {}", int_to_word(&thousands_val), THOUSAND);
235 if !remainder.is_zero() {
236 result.push(' ');
237 result.push_str(&int_to_word(&remainder));
238 }
239 return result;
240 }
241
242 if n < &billion {
243 let (millions_val, remainder) = n.div_rem(&million);
244 let mut result = format!("{} {}", int_to_word(&millions_val), MILLION);
245 if !remainder.is_zero() {
246 result.push(' ');
247 result.push_str(&int_to_word(&remainder));
248 }
249 return result;
250 }
251
252 // `return str(number)` — the fallback for "very large numbers".
253 n.to_string()
254}
255
256/// Python's `int(s)` on the integer fragment `parts[0]`/`n` of `str(number)`.
257///
258/// `int()` accepts only a plain optionally-signed digit run, so a fragment in
259/// exponent notation (`"1e+16"`) raises `ValueError`. The reconstructed strings
260/// never carry an exponent (see [`float_to_str`]), so this is unreachable for
261/// every corpus row — but it is reproduced verbatim, including Python's message,
262/// for the scientific-notation float that `str()` would produce (see the port
263/// report).
264fn parse_int(s: &str) -> Result<BigInt> {
265 BigInt::from_str(s).map_err(|_| {
266 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
267 })
268}
269
270/// Python's `int(digit)` on a *single* character of the fractional string,
271/// inside `Num2Word_LN.to_cardinal`'s float branch (`_int_to_word(int(digit))`).
272///
273/// `int('5')` -> 5; `int('e')` raises `ValueError` with the char quoted. Our
274/// reconstructed fractional part is always ASCII `0-9`, so the error arm only
275/// fires for the exponent-notation repr LN itself chokes on.
276fn parse_digit(ch: char) -> Result<BigInt> {
277 match ch.to_digit(10) {
278 Some(d) => Ok(BigInt::from(d)),
279 None => Err(N2WError::Value(format!(
280 "invalid literal for int() with base 10: '{}'",
281 ch
282 ))),
283 }
284}
285
286/// Reconstruct Python's `str(number)` from a `FloatValue`.
287///
288/// `Num2Word_LN.to_cardinal` does no float arithmetic — it `str()`s the input
289/// and splits on `"."`. So the float path must reproduce `str` exactly, **not**
290/// `base.float2tuple`: `str(float)` is `repr` (shortest round-trip, exponent
291/// form at `|v| >= 1e16`) and `str(Decimal)` is `_pydecimal.__str__` (scale
292/// preserved, `E+n` form for a positive exponent). The exponent forms are
293/// load-bearing: `str(1e16) == "1e+16"` has no `"."`, so Python's `int()`
294/// raises `ValueError` — the digit-perfect fixed-notation reconstruction the
295/// previous revision used silently *succeeded* there. Helpers mirror
296/// `lang_sk.rs`/`lang_cs.rs`.
297fn float_to_str(v: &FloatValue) -> String {
298 match v {
299 FloatValue::Float { value, .. } => python_repr_f64(*value),
300 FloatValue::Decimal { value, .. } => python_str_decimal(value),
301 }
302}
303
304/// CPython's `repr(float)` (== `str(float)`): shortest round-trip digits,
305/// fixed notation iff `-4 < decpt <= 16`, `.0` appended when integral,
306/// two-digit-padded exponent otherwise. Mirrors `lang_sk.rs`'s
307/// `python_repr_f64`.
308fn python_repr_f64(f: f64) -> String {
309 if f.is_nan() {
310 return "nan".to_string();
311 }
312 if f.is_infinite() {
313 return if f < 0.0 { "-inf" } else { "inf" }.to_string();
314 }
315
316 // `is_sign_negative` captures -0.0, whose repr is '-0.0'.
317 let neg = f.is_sign_negative();
318 let s = format!("{:e}", f.abs()); // e.g. "1.234e1", "5e-1", "0e0"
319 let (mantissa, exp_s) = match s.split_once('e') {
320 Some(parts) => parts,
321 None => return s, // unreachable for finite f64
322 };
323 let exp: i32 = exp_s.parse().unwrap_or(0);
324 let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
325 let ndigits = digits.len() as i32;
326 let decpt = exp + 1;
327
328 let body = if decpt <= -4 || decpt > 16 {
329 // Scientific: first digit, optional ".rest", then "e±NN".
330 let mut m = String::new();
331 m.push_str(&digits[..1]);
332 if ndigits > 1 {
333 m.push('.');
334 m.push_str(&digits[1..]);
335 }
336 let e = decpt - 1;
337 let (esign, eabs) = if e < 0 { ('-', -e) } else { ('+', e) };
338 format!("{}e{}{:02}", m, esign, eabs)
339 } else if decpt <= 0 {
340 format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
341 } else if decpt >= ndigits {
342 format!("{}{}.0", digits, "0".repeat((decpt - ndigits) as usize))
343 } else {
344 let dp = decpt as usize;
345 format!("{}.{}", &digits[..dp], &digits[dp..])
346 };
347
348 if neg {
349 format!("-{}", body)
350 } else {
351 body
352 }
353}
354
355/// CPython's `str(Decimal)`: trailing zeros preserved (`"1.10"` stays
356/// `"1.10"`), `E±n` notation exactly when `exp > 0` or the adjusted exponent
357/// `< -6` — so `Decimal("1E+3")` prints `"1E+3"` and feeds `int()` a
358/// `ValueError`. Mirrors `lang_sk.rs`'s `python_str_decimal`.
359fn python_str_decimal(bd: &BigDecimal) -> String {
360 let (coeff, scale) = bd.as_bigint_and_exponent();
361 let py_exp: i64 = -scale; // Decimal._exp
362 let neg = coeff.is_negative(); // BigInt drops the sign of a negative zero
363 let int_str = coeff.abs().to_string(); // Decimal._int
364 let ndigits = int_str.len() as i64;
365 let leftdigits = py_exp + ndigits;
366
367 // dotplace, non-engineering branch of _pydecimal.__str__.
368 let dotplace: i64 = if py_exp <= 0 && leftdigits > -6 {
369 leftdigits
370 } else {
371 1
372 };
373
374 let (intpart, fracpart) = if dotplace <= 0 {
375 (
376 "0".to_string(),
377 format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
378 )
379 } else if dotplace >= ndigits {
380 (
381 format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
382 String::new(),
383 )
384 } else {
385 let dp = dotplace as usize;
386 (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
387 };
388
389 let exp = if leftdigits == dotplace {
390 String::new()
391 } else {
392 // "%+d" — a sign but no zero-padding, unlike float repr.
393 format!("E{:+}", leftdigits - dotplace)
394 };
395
396 let sign = if neg { "-" } else { "" };
397 format!("{}{}{}{}", sign, intpart, fracpart, exp)
398}
399
400/// Port of `Num2Word_LN.to_cardinal` operating on the reconstructed
401/// `str(number)`:
402///
403/// ```python
404/// n = str(number).strip()
405/// if n.startswith("-"):
406/// n = n[1:]
407/// ret = self.negword
408/// else:
409/// ret = ""
410/// if "." in n:
411/// left, right = n.split(".", 1)
412/// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
413/// for digit in right:
414/// ret += self._int_to_word(int(digit)) + " "
415/// return ret.strip()
416/// else:
417/// return (ret + self._int_to_word(int(n))).strip()
418/// ```
419///
420/// Note the fraction uses `_int_to_word(int(digit))` — the full recursive
421/// helper, so a `0` digit becomes `"zero"` (via [`int_to_word`]'s zero arm), not
422/// the empty `ones[0]`. LN sets `ret = negword` up front rather than recursing
423/// (unlike FF/HA), so this mirrors that structure exactly.
424fn cardinal_from_str(s: &str) -> Result<String> {
425 // n = str(number).strip()
426 let n_full = s.trim();
427 // if n.startswith("-"): n = n[1:]; ret = self.negword else: ret = ""
428 let (n, ret) = match n_full.strip_prefix('-') {
429 Some(rest) => (rest, NEGWORD),
430 None => (n_full, ""),
431 };
432
433 if let Some((left, right)) = n.split_once('.') {
434 // ret += self._int_to_word(int(left)) + " " + self.pointword + " "
435 let left_int = parse_int(left)?;
436 let mut out = String::from(ret);
437 out.push_str(&int_to_word(&left_int));
438 out.push(' ');
439 out.push_str(POINTWORD);
440 out.push(' ');
441 // for digit in right: ret += self._int_to_word(int(digit)) + " "
442 for ch in right.chars() {
443 let d = parse_digit(ch)?;
444 out.push_str(&int_to_word(&d));
445 out.push(' ');
446 }
447 // return ret.strip()
448 Ok(out.trim().to_string())
449 } else {
450 // return (ret + self._int_to_word(int(n))).strip()
451 let ni = parse_int(n)?;
452 Ok(format!("{}{}", ret, int_to_word(&ni)).trim().to_string())
453 }
454}
455
456pub struct LangLn {
457 /// `Num2Word_LN.CURRENCY_FORMS`. Built once in [`LangLn::new`] and only
458 /// read afterwards — the generated registry parks each language in a
459 /// `OnceLock` and calls `new` through `get_or_init`, so this table is
460 /// constructed once per process rather than once per conversion.
461 currency_forms: HashMap<&'static str, CurrencyForms>,
462 /// `list(self.CURRENCY_FORMS.values())[0]` — what `.get`'s default falls
463 /// back to for an unknown code (quirk 6).
464 ///
465 /// Python re-evaluates that expression on every call, and under CPython's
466 /// insertion-ordered dicts it resolves to the *first inserted* entry:
467 /// `CDF`. A `HashMap` has no first element, so the choice is pinned here
468 /// rather than left to iteration order.
469 fallback_forms: CurrencyForms,
470}
471
472impl Default for LangLn {
473 fn default() -> Self {
474 Self::new()
475 }
476}
477
478impl LangLn {
479 pub fn new() -> Self {
480 let mut currency_forms = HashMap::new();
481 // Insertion order mirrors the Python literal; `fallback_forms` below
482 // depends on CDF being the first entry. Arity is load-bearing:
483 // `to_currency` indexes `cr1[0]`/`cr1[1]` and `cr2[0]`/`cr2[1]`, so
484 // both forms of both tuples must survive verbatim — including CDF's,
485 // where singular and plural are deliberately identical.
486 currency_forms.insert(
487 "CDF",
488 CurrencyForms::new(&["faranga", "faranga"], &["santimi", "santimi"]),
489 );
490 currency_forms.insert(
491 "USD",
492 CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
493 );
494 currency_forms.insert(
495 "EUR",
496 CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
497 );
498 let fallback_forms = currency_forms
499 .get("CDF")
500 .expect("CURRENCY_FORMS[\"CDF\"] is inserted directly above")
501 .clone();
502 LangLn {
503 currency_forms,
504 fallback_forms,
505 }
506 }
507}
508
509impl Lang for LangLn {
510 /// This language's own `to_currency(currency=...)` default,
511 /// read from the live Python signature. Only 44 of 156 use EUR.
512 fn default_currency(&self) -> &str {
513 "CDF"
514 }
515
516 /// This language's own `to_currency(separator=...)` default,
517 /// read from the live Python signature. Base's is ",", but only
518 /// 36 of 149 languages actually use it — most default to " " or a
519 /// conjunction, so inheriting Base's comma silently corrupts them.
520 fn default_separator(&self) -> &str {
521 " "
522 }
523
524 fn negword(&self) -> &str {
525 NEGWORD
526 }
527
528 fn pointword(&self) -> &str {
529 "point"
530 }
531
532 /// Python:
533 /// ```python
534 /// n = str(number).strip()
535 /// if n.startswith("-"):
536 /// n = n[1:]
537 /// ret = self.negword
538 /// else:
539 /// ret = ""
540 /// ...
541 /// return (ret + self._int_to_word(int(n))).strip()
542 /// ```
543 /// For integer input the `str()`/`int()` round-trip is an identity and the
544 /// `"." in n` branch is unreachable, so the observable behaviour reduces
545 /// to: strip the sign, word the magnitude, prefix `negword`. The trailing
546 /// `.strip()` is a no-op on every reachable value (nothing produces
547 /// leading or trailing whitespace) but is kept for fidelity.
548 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
549 let (ret, magnitude) = if value.is_negative() {
550 (NEGWORD, value.abs())
551 } else {
552 ("", value.clone())
553 };
554 Ok(format!("{}{}", ret, int_to_word(&magnitude))
555 .trim()
556 .to_string())
557 }
558
559 /// Python: `return self.to_cardinal(number) + "-e"`. No negative guard, no
560 /// separator — the suffix fuses onto the final word (and onto the digit
561 /// fallback for values >= 10^9).
562 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
563 Ok(format!("{}-e", self.to_cardinal(value)?))
564 }
565
566 /// Python: `return str(number) + "."` — language-independent, and keeps
567 /// the minus sign for negatives.
568 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
569 Ok(format!("{}.", value))
570 }
571
572 // to_year is intentionally not overridden: Python's
573 // `to_year(self, val, longval=True)` ignores `longval` and returns
574 // `self.to_cardinal(val)`, which is precisely what the trait default does.
575
576 /// The float/Decimal branch of `Num2Word_LN.to_cardinal`.
577 ///
578 /// LN does **not** override `to_cardinal_float`; it overrides `to_cardinal`
579 /// and handles non-integers inline via `str(number).split(".", 1)`. So the
580 /// inherited `default_to_cardinal_float` (which drives `base.float2tuple`)
581 /// is the wrong engine — it re-derives the fraction through binary
582 /// arithmetic and can drop a low-order digit at large magnitude. This
583 /// override rebuilds `str(number)` and runs LN's own string algorithm.
584 ///
585 /// `precision_override` is ignored: `Num2Word_LN.to_cardinal(self, number)`
586 /// takes no `precision=` parameter and never reads `self.precision`, so the
587 /// kwarg has no effect in Python either — verified live:
588 /// `num2words(2.675, lang="ln", precision=1)` is unchanged. The
589 /// reconstructed string uses the value's repr-derived precision, exactly as
590 /// `str()` does.
591 ///
592 /// Faithfully reproduced quirks:
593 /// * `1.0` (float) -> `"moko point zero"`: `str(1.0)` is `"1.0"`, so the
594 /// `"."` branch fires and the trailing `"0"` digit -> `"zero"`.
595 /// * `Decimal("1.10")` -> `"moko point moko zero"`: the trailing zero is a
596 /// real fractional digit (unlike the float `1.1`).
597 /// * `Decimal("98746251323029.99")` -> `"98746251323029 point libwá
598 /// libwá"`: the >=10^9 integer part falls off `int_to_word`'s cliff to
599 /// bare digits (issue #603 value), but the fraction is still spelled.
600 /// * A negative with a zero integer part keeps its sign because the sign
601 /// lives in the *string* (`"-0.5"`), not in a truncated int:
602 /// `-0.5` -> `"minus zero point mítáno"`.
603 fn to_cardinal_float(
604 &self,
605 value: &FloatValue,
606 _precision_override: Option<u32>,
607 ) -> Result<String> {
608 cardinal_from_str(&float_to_str(value))
609 }
610
611 /// `to_cardinal(float/Decimal)` — the FULL entry. Python routes *every*
612 /// float/Decimal through the `str(number)` algorithm, so a whole value
613 /// keeps its visible point: `5.0` -> "mítáno point zero", `-0.0` ->
614 /// "minus zero point zero", `Decimal("5.00")` -> "mítáno point zero zero".
615 /// The base default's whole-value integer shortcut must not fire here.
616 /// Exponent-form values (`1e16`, `Decimal("1E+2")`) raise `int()`'s
617 /// ValueError from inside the string algorithm, exactly as Python.
618 fn cardinal_float_entry(
619 &self,
620 value: &FloatValue,
621 precision_override: Option<u32>,
622 ) -> Result<String> {
623 self.to_cardinal_float(value, precision_override)
624 }
625
626 /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-e"`, where
627 /// that cardinal is the `str(number)` algorithm — so "mítáno point
628 /// zero-e" for 5.0, and the exponent forms raise ValueError before the
629 /// suffix is appended.
630 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
631 Ok(format!("{}-e", self.to_cardinal_float(value, None)?))
632 }
633
634 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`, verbatim.
635 /// `repr_str` is the binding's Python `str(value)`, exactly the string
636 /// Python concatenates — so even exponent forms come out fine:
637 /// `to_ordinal_num(1e16)` == "1e+16.".
638 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
639 Ok(format!("{}.", repr_str))
640 }
641
642 /// `to_year(float/Decimal)`: LN's `to_year` forwards to `to_cardinal`,
643 /// which for a float/Decimal is the string algorithm above.
644 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
645 self.to_cardinal_float(value, None)
646 }
647
648 /// LN inherits Base's `str_to_number` (`Decimal(value)`), which parses
649 /// "Infinity"/"-Infinity"/"NaN" *successfully*. The mode-dependent outcome
650 /// is served natively by `inf_result` / `nan_result` below, so nothing
651 /// punts to the Python fallback.
652 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
653 let parsed = python_decimal_parse(s)?;
654 Ok(parsed)
655 }
656
657 /// `Decimal('Infinity')` / `-Infinity` per mode. LN's converters read
658 /// `str(number)`:
659 ///
660 /// * `to_cardinal` (also `to_ordinal` = cardinal + "-e", and `to_year` =
661 /// cardinal) strip the sign then `int("Infinity")` → `ValueError` — not
662 /// the base OverflowError.
663 /// * `to_ordinal_num` is `str(number) + "."`; nothing is parsed, so it
664 /// yields "Infinity." / "-Infinity.".
665 fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
666 match to {
667 "ordinal_num" => Ok(format!(
668 "{}.",
669 if negative { "-Infinity" } else { "Infinity" }
670 )),
671 // Sign is peeled off the string before `int()`, so the message
672 // quotes the bare token.
673 _ => Err(parse_int("Infinity").unwrap_err()),
674 }
675 }
676
677 /// `Decimal('NaN')` per mode. The parsing modes `int("NaN")` → ValueError;
678 /// `to_ordinal_num` returns "NaN." unparsed.
679 fn nan_result(&self, to: &str) -> Result<String> {
680 match to {
681 "ordinal_num" => Ok("NaN.".to_string()),
682 _ => Err(parse_int("NaN").unwrap_err()),
683 }
684 }
685
686 // ---- currency ------------------------------------------------------
687 //
688 // Only the three hooks below are overridden. LN defines no
689 // CURRENCY_ADJECTIVES and no CURRENCY_PRECISION, and leaves `pluralize`,
690 // `_money_verbose`, `_cents_verbose`, `_cents_terse` and `to_cheque` at
691 // `Num2Word_Base`, whose behaviour the trait defaults already reproduce:
692 //
693 // * `currency_adjective` -> None; LN's CURRENCY_ADJECTIVES is `{}` and
694 // `to_currency` never calls `prefix_currency` anyway.
695 // * `currency_precision` -> 100; LN's CURRENCY_PRECISION is `{}`, so
696 // `.get(code, 100)` is 100 for every code. Only `to_cheque` reads it
697 // (LN's `to_currency` hardcodes two digits instead).
698 // * `pluralize` -> the default raises NotImplementedError, and
699 // that is correct: LN inherits the abstract `Num2Word_Base.pluralize`
700 // and nothing reachable calls it. `to_currency` is overridden and picks
701 // forms by hand; base's `to_cheque` never pluralizes.
702 // * `money_verbose` -> `self.to_cardinal(number)`, which routes
703 // through LN's `to_cardinal` override. This is what gives `to_cheque`
704 // its Lingala words.
705 // * `cents_verbose` / `cents_terse` -> unreachable; LN's `to_currency`
706 // replaces the only code path that would call them.
707
708 /// `self.__class__.__name__`, for the inherited `to_cheque`'s
709 /// `NotImplementedError` message. Verified verbatim against the
710 /// interpreter: `Currency code "GBP" not implemented for "Num2Word_LN"`.
711 fn lang_name(&self) -> &str {
712 "Num2Word_LN"
713 }
714
715 /// `CURRENCY_FORMS[code]` — a strict lookup that misses for anything but
716 /// CDF/USD/EUR.
717 ///
718 /// Deliberately **not** the `.get(code, <CDF>)` fallback of quirk 6: that
719 /// fallback is local to `to_currency`. The inherited `to_cheque`
720 /// subscripts `CURRENCY_FORMS[currency]` and turns the `KeyError` into
721 /// `NotImplementedError`, so it must still see a miss here — that is
722 /// exactly what makes `cheque:GBP` raise while `currency:GBP` quietly
723 /// prints faranga. Folding the fallback in here would silence the six
724 /// `NotImplementedError` cheque rows in the corpus.
725 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
726 self.currency_forms.get(code)
727 }
728
729 /// Port of `Num2Word_LN.to_currency`.
730 ///
731 /// LN ignores `base.to_currency` wholesale — no `parse_currency_parts`, no
732 /// `divisor`, no `pluralize`. It slices the decimal *string*:
733 ///
734 /// ```python
735 /// parts = str(val).split(".")
736 /// left = int(parts[0]) if parts[0] else 0
737 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
738 /// ```
739 ///
740 /// # The `separator` argument is not what Python's default would be
741 ///
742 /// LN's own signature defaults to `separator=" "`, but the trait cannot
743 /// carry a per-language default argument: the dispatcher resolves it
744 /// before the call and passes `Num2Word_Base`'s `","`. The frozen corpus
745 /// was generated through Python — where LN's `" "` applies — so `","` is
746 /// read back as the "unset" sentinel it is. This is exact both for a caller
747 /// who omits `separator` and for one who passes anything other than `","`,
748 /// and wrong only for an explicit `separator=","`, a case the bridge cannot
749 /// distinguish from the default anyway. See `concerns`; `lang_br.rs` (whose
750 /// Python `to_currency` is byte-identical to LN's) and `lang_es.rs` take
751 /// the same approach for the same reason.
752 fn to_currency(
753 &self,
754 val: &CurrencyValue,
755 currency: &str,
756 cents: bool,
757 separator: Option<&str>,
758 adjective: bool,
759 ) -> Result<String> {
760 // Trait now hands us None when the caller omitted separator=;
761 // resolve it to this language's own default before the ported body.
762 let separator = separator.unwrap_or(self.default_separator());
763 // Python accepts `adjective` and never reads it: LN has no
764 // CURRENCY_ADJECTIVES and never calls prefix_currency, so
765 // `adjective=True` is byte-identical to the plain call.
766 let _ = adjective;
767
768 let separator = if separator == BASE_DEFAULT_SEPARATOR {
769 DEFAULT_SEPARATOR
770 } else {
771 separator
772 };
773
774 // `if val < 0: is_negative = True; val = abs(val)` runs *before*
775 // `str(val)`, so the string never carries a sign. The abs() is
776 // conditional in Python, so it stays conditional here.
777 //
778 // The Int/Decimal split is preserved even though LN, uniquely, cannot
779 // observe it in the output: it never calls `isinstance(val, int)`, and
780 // an int `1` ("1" -> no cents) and a float `1.0` ("1.0" -> right == 0,
781 // falsy -> no cents) converge on "moko euro" by different routes. The
782 // branch still matters because `str()` differs between the two.
783 let is_negative = val.is_negative();
784 let s = match val {
785 CurrencyValue::Int(v) => {
786 if is_negative {
787 v.abs().to_string()
788 } else {
789 v.to_string()
790 }
791 }
792 CurrencyValue::Decimal { value: d, .. } => {
793 if is_negative {
794 d.abs().to_string()
795 } else {
796 d.to_string()
797 }
798 }
799 };
800
801 // `str(val).split(".")` splits on every dot; Python then reads only
802 // parts[0] and parts[1], so any trailing fragment is ignored either
803 // way.
804 let mut parts = s.split('.');
805 let part0 = parts.next().unwrap_or("");
806 let part1 = parts.next();
807
808 // `int(parts[0]) if parts[0] else 0`.
809 let left = if part0.is_empty() {
810 BigInt::zero()
811 } else {
812 BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
813 };
814
815 // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
816 //
817 // `[:2]` truncates and `ljust` pads, so "5" -> "50" (0.5 is 50
818 // subunits) and "01" -> "01" (0.01 is 1). Sliced by chars, not bytes.
819 // A whole float gives "0" -> "00" -> 0, which is falsy (quirk 8).
820 let right = match part1 {
821 Some(f) if !f.is_empty() => {
822 let mut two: String = f.chars().take(2).collect();
823 while two.chars().count() < 2 {
824 two.push('0');
825 }
826 BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
827 }
828 _ => BigInt::zero(),
829 };
830
831 // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — quirk 6.
832 let forms = self
833 .currency_forms
834 .get(currency)
835 .unwrap_or(&self.fallback_forms);
836 let cr1 = &forms.unit;
837 let cr2 = &forms.subunit;
838
839 let one = BigInt::one();
840
841 // `left_str + " " + (cr1[1] if left != 1 else cr1[0])`. Note that this
842 // is `self._int_to_word(left)`, *not* `self.to_cardinal(left)` — so the
843 // 10^9 digit fallback applies here too. Zero takes the plural
844 // ("zero euros", quirk 10).
845 let mut result = format!(
846 "{} {}",
847 int_to_word(&left),
848 if left != one { &cr1[1] } else { &cr1[0] }
849 );
850
851 // `if cents and right:` — `right` is an int, so 0 is falsy and a float
852 // with zero cents drops the whole segment (quirk 8). `cents=False`
853 // drops it too, with no terse fallback (quirk 9).
854 if cents && !right.is_zero() {
855 result.push_str(separator);
856 result.push_str(&int_to_word(&right));
857 result.push(' ');
858 result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
859 }
860
861 // `result = self.negword + result` — raw, keeping the trailing space of
862 // "minus ".
863 if is_negative {
864 result = format!("{}{}", NEGWORD, result);
865 }
866
867 // `result.strip()`. A no-op for every reachable input, but it is what
868 // Python writes.
869 Ok(result.trim().to_string())
870 }
871
872 // to_cheque is intentionally not overridden. `Num2Word_LN` does not define
873 // it, so Python uses `Num2Word_Base.to_cheque`, which the trait default
874 // (`currency::default_to_cheque`) reproduces: strict CURRENCY_FORMS lookup
875 // -> NotImplementedError, divisor 100 from the empty CURRENCY_PRECISION,
876 // `int(abs_val)` whole part, `"%0*d/%d"` fraction, `_money_verbose` (->
877 // LN's to_cardinal) for the words, `cr1[-1]` for the always-plural unit,
878 // and `.upper()` over the lot. Traced against the interpreter for
879 // cheque:EUR/USD/GBP at 1234.56.
880}