Skip to main content

num2words2_core/
lang_fo.rs

1//! Port of `lang_FO.py` (Faroese).
2//!
3//! Shape: **self-contained**. `Num2Word_FO` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `hasattr` guard in `Num2Word_Base.__init__` never fires: Python builds no
6//! `self.cards` and never sets `self.MAXVAL`. `to_cardinal` is overridden
7//! outright and drives a plain recursive `_int_to_word`. Consequently
8//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
9//! **no overflow check** — `to_cardinal` never raises for any integer input
10//! (see bug 3 below for what happens past 10^9 instead).
11//!
12//! All four in-scope modes are overridden by Python, so nothing here falls
13//! through to a `Num2Word_Base` default:
14//!   * `to_cardinal(number)`  → strips the sign off `str(number)`, recurses.
15//!   * `to_ordinal(number)`   → `to_cardinal(number) + "-ti"`, unconditionally.
16//!   * `to_ordinal_num(number)` → `str(number) + "."`.
17//!   * `to_year(val, longval=True)` → `to_cardinal(val)`; `longval` is accepted
18//!     and then ignored, so years get no century split and no era suffix.
19//!
20//! # Faithfully reproduced Python bugs
21//!
22//! This is a port, not a rewrite. Everything below is wrong — as Faroese and
23//! sometimes as arithmetic — but is exactly what Python emits, verified
24//! against the frozen corpus:
25//!
26//! 1. **There are no teens.** `self.tens[1]` is "tíggju" (= 10) and 11..=19 are
27//!    built by the generic `tens[n/10] + " " + ones[n%10]` rule, so
28//!    `to_cardinal(11)` == "tíggju ein" — literally "ten one". Real Faroese is
29//!    "ellivu". Likewise 12 → "tíggju tvey" (not "tólv"), 13 → "tíggju trý"
30//!    (not "trettan"), ... 19 → "tíggju níggju" (not "nítjan"). This leaks
31//!    upward: `to_cardinal(12345)` == "tíggju tvey túsund trý hundrað fjøruti
32//!    fimm", i.e. the thousands part reads "ten two thousand".
33//! 2. **No copula anywhere.** Faroese joins the last two elements with "og"
34//!    ("hundrað og ein"); this module joins every fragment with a bare space,
35//!    so 101 → "ein hundrað ein". Numerals are also never inflected for gender
36//!    ("tvey"/"trý" are the neuter forms, used for everything).
37//! 3. **Everything >= 10^9 falls back to digits.** The `_int_to_word` chain
38//!    stops at `million`, and the final `else` is `return str(number)` — a
39//!    literal comment-flagged "Fallback for very large numbers". So
40//!    `to_cardinal(10**9)` == "1000000000" (a digit string, not words) and
41//!    `to_cardinal(10**21)` == "1000000000000000000000". No exception is
42//!    raised. Modelled by the last arm of [`LangFo::int_to_word`].
43//! 4. **The 10^9 fallback poisons the ordinal.** `to_ordinal` appends "-ti" to
44//!    whatever `to_cardinal` returned without inspecting it, so
45//!    `to_ordinal(10**9)` == "1000000000-ti" — digits with a word suffix glued
46//!    on. Confirmed in the corpus.
47//! 5. **`to_ordinal` accepts negatives.** Python never calls
48//!    `Num2Word_Base.verify_ordinal`, which would have raised `TypeError` for
49//!    negative input. So `to_ordinal(-1)` == "minus ein-ti" rather than
50//!    raising, and `to_ordinal_num(-1)` == "-1." (`str(-1) + "."`).
51//! 6. **"-ti" is not an ordinal suffix**, it is the tens suffix ("fimmti" =
52//!    50). Every ordinal is formed this way regardless of the stem, so
53//!    `to_ordinal(1)` == "ein-ti" where Faroese wants "fyrsti". The hyphen is
54//!    literal and always present.
55//! 7. `zero` is an English word. `_int_to_word(0)` reads
56//!    `self.ones[0] if self.ones[0] else "zero"`; `ones[0]` is `""` — falsy —
57//!    so the guard *always* takes the fallback and 0 → "zero", never the
58//!    Faroese "null". `pointword` is likewise the English "point".
59//!
60//! # Error variants
61//!
62//! The four *integer* modes never raise: there is no `MAXVAL` to overflow,
63//! every table index is bounded by its enclosing branch, and negatives are
64//! handled rather than rejected. The integer corpus for "fo" is `ok: true`
65//! across `cardinal`/`ordinal`/`ordinal_num`/`year`.
66//!
67//! The **float/Decimal** path *can* raise, and only one way: `to_cardinal`
68//! re-parses the sign-stripped `str(number)` with `int()`, so any point-less
69//! form that is not an integer literal — `repr(1e16)` == "1e+16",
70//! `str(Decimal("1E+2"))` == "1E+2", "inf"/"nan"/"Infinity" — raises
71//! `ValueError: invalid literal for int() with base 10: '...'`. Values whose
72//! string *does* show a point never raise, whatever their size (the >= 10^9
73//! integer part just leaks digits, bug 3). Routing is pinned by the
74//! wholefloat corpus: `13x ValueError` rows for 1e+16/1e+20/1E+2/1E+20 across
75//! cardinal/ordinal/year, and `ok` rows for every pointed form including
76//! "-0.0" -> "minus zero point zero" (see [`route_by_str`]).
77//!
78//! `to_currency` never raises either — see bug 8 below, it has no
79//! `NotImplementedError` path at all. The **only** raising surface in this
80//! module is `to_cheque`, which `Num2Word_FO` does *not* override: the
81//! inherited `Num2Word_Base.to_cheque` subscripts `CURRENCY_FORMS[currency]`
82//! and converts the `KeyError` into `NotImplementedError`. So `cheque:GBP`
83//! raises while `currency:GBP` happily prints krónur.
84//!
85//! # Currency surface
86//!
87//! `Num2Word_FO` overrides `to_currency` **wholesale** — it never calls
88//! `Num2Word_Base.to_currency`, `parse_currency_parts`, `pluralize`,
89//! `_cents_verbose` or `_cents_terse`. Consequently:
90//!
91//! * `pluralize` is never reached and stays at its (raising) trait default,
92//!   exactly as `Num2Word_FO` leaves `Num2Word_Base.pluralize` abstract.
93//! * `CURRENCY_PRECISION` is `{}` (inherited from `Num2Word_Base`), so
94//!   `.get(code, 100)` is always 100 — `currency_precision` stays at the trait
95//!   default. FO's `to_currency` does not consult it anyway (bug 9).
96//! * `CURRENCY_ADJECTIVES` is `{}` and `adjective` is accepted-then-ignored
97//!   (bug 10), so `currency_adjective` stays at its `None` default.
98//! * `money_verbose` stays at its default (`self.to_cardinal`), which is what
99//!   `to_cheque` needs — `Num2Word_FO` does not override `_money_verbose`.
100//!
101//! ## Further faithfully reproduced Python bugs
102//!
103//! 8. **An unknown currency code silently becomes DKK.** FO's `to_currency`
104//!    reads `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
105//!    — a `.get` with a *default*, not a subscript. `CURRENCY_FORMS` is
106//!    declared `{"DKK": ..., "USD": ..., "EUR": ...}` and dicts preserve
107//!    insertion order, so `values()[0]` is DKK's pair. Every unrecognised code
108//!    is therefore rendered in Faroese krónur under its own name:
109//!    `to_currency(1.0, "GBP")` == "ein króna", `to_currency(12.34, "JPY")` ==
110//!    "tíggju tvey krónur tríati fýra oyru". The corpus pins GBP/JPY/KWD/BHD/
111//!    INR/CNY/CHF to exactly that. Modelled by [`FALLBACK_CURRENCY`].
112//! 9. **`CURRENCY_PRECISION` is ignored, so every currency splits at 2
113//!    decimals.** The cents are cut out of the *digit string* with
114//!    `parts[1][:2]`, a hard-coded 2. 3-decimal codes (KWD/BHD) and 0-decimal
115//!    ones (JPY) get cent-style treatment like everything else — and since
116//!    bug 8 has already rewritten them to DKK, `currency:JPY` for 12.34 is
117//!    "tíggju tvey krónur tríati fýra oyru" rather than the ¥12 a JPY-aware
118//!    path would give.
119//! 10. **`adjective` is dead.** The parameter is declared and never read, so
120//!    `adjective=True` changes nothing (there are no `CURRENCY_ADJECTIVES` to
121//!    apply either).
122//! 11. **Cents are dropped whenever they round to zero**, `int` or `float`
123//!    alike. The guard is `if cents and right:` — a plain truthiness test on
124//!    the cent count, *not* `Num2Word_Base`'s `isinstance(val, int)` check. So
125//!    FO is one of the rare languages where `1` and `1.0` agree: both are "ein
126//!    euro", never "ein euro null cents". `0.001` likewise truncates to zero
127//!    cents and prints "zero euros".
128//! 12. **The cent digits are truncated, never rounded**, and are read
129//!    left-to-right off the string: `12.999` → `"999"[:2]` → 99 cents, and
130//!    `0.5` → `"5".ljust(2, "0")` → "50" → 50 cents. So a trailing digit is
131//!    dropped rather than rounded up (`.999` does not carry to the next unit).
132
133use crate::base::{Lang, N2WError, Result};
134use crate::currency::{CurrencyForms, CurrencyValue};
135use crate::floatpath::FloatValue;
136use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
137use bigdecimal::BigDecimal;
138use num_bigint::BigInt;
139use num_integer::Integer;
140use num_traits::{One, Signed, ToPrimitive, Zero};
141use std::collections::HashMap;
142
143/// `self.negword`. Note the **trailing space**, which is part of the literal
144/// in Python (`"minus "`) and differs from the `Num2Word_Base` convention.
145/// `to_cardinal` concatenates it raw and lets the final `.strip()` tidy up.
146const NEGWORD: &str = "minus ";
147
148/// `self.pointword`. English, verbatim from Python (bug 7's sibling): every
149/// pointed value reads "... point ...", never the Faroese "komma".
150const POINTWORD: &str = "point";
151
152/// `self.ones`. Index 0 is the empty string — see bug 7.
153const ONES: [&str; 10] = [
154    "", "ein", "tvey", "trý", "fýra", "fimm", "seks", "sjey", "átta", "níggju",
155];
156
157/// `self.tens`. Index 1 is "tíggju" (10) and there is no teens table — see
158/// bug 1.
159const TENS: [&str; 10] = [
160    "", "tíggju", "tjúgu", "tríati", "fjøruti", "fimmti", "seksti", "sjeyti", "áttati", "níti",
161];
162
163const HUNDRED: &str = "hundrað";
164const THOUSAND: &str = "túsund";
165const MILLION: &str = "millión";
166
167/// The Python class name, for `Num2Word_Base.to_cheque`'s NotImplementedError
168/// message (`self.__class__.__name__`).
169const LANG_NAME: &str = "Num2Word_FO";
170
171/// `Num2Word_FO.to_currency`'s own default `separator=" "` — a bare space.
172const SEPARATOR_DEFAULT: &str = " ";
173
174/// The separator the pyo3 binding hands us when the Python caller omitted one.
175///
176/// `Num2Word_FO.to_currency` declares `separator=" "`, but the `Lang` trait
177/// carries no per-language parameter defaults, and both `__init__.py`'s
178/// currency fast path and `bench/diff_test.py` substitute
179/// `kwargs.get("separator", ",")` — **`Num2Word_Base`'s** default, not FO's —
180/// before the value crosses the boundary. By then "caller omitted separator"
181/// and "caller explicitly passed a comma" are the same `&str`, and the
182/// information needed to tell them apart no longer exists on this side.
183///
184/// So `,` is read back as the unset sentinel and FO's own default restored.
185/// This is the only reading the oracle supports: every float row of the `fo`
186/// currency corpus was generated by `num2words(v, lang="fo", to="currency",
187/// currency=c)` with no `separator=`, and all 54 of them expect a bare space
188/// ("tíggju tvey euros tríati fýra cents", not "...euros,tríati fýra cents").
189///
190/// The cost is narrow and known: a caller who *explicitly* passes
191/// `separator=","` gets " " where Python would give ",". Expressing that case
192/// needs `Option<&str>` in the trait signature, which lives in `base.rs` —
193/// outside this port's remit. Flagged in the port report. `lang_bo.rs`,
194/// `lang_ca.rs`, `lang_es.rs` and `lang_eu.rs` resolve the identical conflict
195/// the same way.
196const SEPARATOR_UNSET: &str = ",";
197
198/// `to_currency`'s fallback code — see bug 8.
199///
200/// Python spells it `list(self.CURRENCY_FORMS.values())[0]`, i.e. "whichever
201/// entry was inserted first". `CURRENCY_FORMS` is a dict literal ordered
202/// `DKK, USD, EUR` and dicts preserve insertion order, so that is DKK. Pinning
203/// the *key* rather than reproducing a positional lookup keeps the intent
204/// legible; a `HashMap` has no insertion order to index into anyway.
205///
206/// Verified against the live interpreter rather than read off the source:
207/// `list(CONVERTER_CLASSES["fo"].CURRENCY_FORMS.keys())` == `['DKK', 'USD',
208/// 'EUR']`. Nothing mutates this dict — `Num2Word_FO` declares its own
209/// `CURRENCY_FORMS` and inherits from `Num2Word_Base`, not `Num2Word_EUR`, so
210/// the `Num2Word_EN.__init__` shared-dict rewrite documented in
211/// PORTING_CURRENCY.md does not reach it.
212const FALLBACK_CURRENCY: &str = "DKK";
213
214/// The hard-coded 2 in `parts[1][:2].ljust(2, "0")` — see bug 9.
215const CENT_DIVISOR: i64 = 100;
216
217/// Python's `int(s)` failure, message verbatim from CPython. `s` is the
218/// point-less `str(number)` slice `to_cardinal` feeds it — sign already
219/// stripped, because the leading "-" is peeled off the string before `int()`
220/// ever runs.
221fn int_value_error(s: &str) -> N2WError {
222    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
223}
224
225/// Python `repr(float)` for the scientific range, on an already-`abs()`ed
226/// finite f64 (`a >= 1e16`, or `0 < a < 1e-4`).
227///
228/// Rust's `{:e}` yields the shortest round-trip mantissa — the same digits
229/// CPython's repr picks — but lays the exponent out as `1e16` / `1e-5` where
230/// Python writes `1e+16` / `1e-05`: always a sign, zero-padded to two digits.
231/// Only the exponent dressing needs fixing up. (Same helper as `lang_ky.rs`,
232/// whose Python module is the same copy-paste family.)
233fn py_float_sci_repr(a: f64) -> String {
234    let sci = format!("{:e}", a); // "1e16", "1.5e20", "1e-5"
235    let (mant, exp_str) = sci.split_once('e').expect("{:e} always emits an 'e'");
236    let exp: i64 = exp_str.parse().expect("{:e} exponent is a base-10 integer");
237    format!(
238        "{}e{}{:02}",
239        mant,
240        if exp < 0 { '-' } else { '+' },
241        exp.abs()
242    )
243}
244
245/// Where `str(number)` sends the value in `to_cardinal` (sign already peeled).
246enum StrRoute {
247    /// `"." in n` — the decimal grammar spells every fractional character.
248    Point,
249    /// No "." and `int(n)` succeeds — `_int_to_word` runs on this magnitude.
250    WholeDigits(BigInt),
251}
252
253/// Reproduce Python's `"." in str(number)` routing, including the `int(n)`
254/// `ValueError` for the point-less forms that are not integer literals.
255///
256/// * **Float** — repr is fixed-notation (always with a ".") for every finite
257///   value outside the scientific range, so the only no-point floats are the
258///   scientific ones (`|v| >= 1e16`, `0 < |v| < 1e-4`) and inf/nan — all of
259///   which make `int()` raise. `FloatValue::has_visible_point` is *not* used
260///   here: it reports `true` for tiny scientific floats (`1e-05`), whose repr
261///   has no point and must raise.
262/// * **Decimal** — `str(Decimal)` is reconstructed by `python_decimal_str`;
263///   a point routes to the decimal grammar, plain digits (canonical integral
264///   Decimals like "100") parse, and everything else ("1E+2", "1E+20") is the
265///   `int()` ValueError.
266fn route_by_str(v: &FloatValue) -> Result<StrRoute> {
267    match v {
268        FloatValue::Float { value, .. } => {
269            let a = value.abs();
270            if !a.is_finite() {
271                // str(float("inf")) == "inf", str(float("nan")) == "nan" —
272                // no ".", so int() raises. The "-" of "-inf" is stripped by
273                // the string-sign peel before int() ever sees it.
274                return Err(int_value_error(if a.is_nan() { "nan" } else { "inf" }));
275            }
276            if a >= 1e16 || (a > 0.0 && a < 1e-4) {
277                // repr picked exponent form: no "." in n -> int(n) raises.
278                return Err(int_value_error(&py_float_sci_repr(a)));
279            }
280            // Every other finite float reprs with a point ("5.0", "0.0001").
281            Ok(StrRoute::Point)
282        }
283        FloatValue::Decimal { value, .. } => {
284            // abs() first: Python strips the string sign before this test.
285            let s = python_decimal_str(&value.abs());
286            if s.contains('.') {
287                Ok(StrRoute::Point)
288            } else if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
289                Ok(StrRoute::WholeDigits(s.parse().expect("digits only")))
290            } else {
291                // "1E+2", "1E+20", ... — int() raises.
292                Err(int_value_error(&s))
293            }
294        }
295    }
296}
297
298pub struct LangFo {
299    /// `Num2Word_FO.CURRENCY_FORMS`.
300    ///
301    /// Built once in [`LangFo::new`] and stored, never per call: the binding
302    /// holds the converter in a `OnceLock`, so this table is constructed
303    /// exactly once per process.
304    ///
305    /// Each entry is a 2-tuple of 2-tuples, `((unit_sg, unit_pl), (sub_sg,
306    /// sub_pl))`. The arity is load-bearing: `to_currency` indexes `[1]` for
307    /// the plural and `to_cheque` takes `cr1[-1]`, so both forms must be
308    /// present and distinct.
309    currency_forms: HashMap<&'static str, CurrencyForms>,
310}
311
312impl LangFo {
313    pub fn new() -> Self {
314        // Insertion order is irrelevant to a HashMap; FALLBACK_CURRENCY
315        // captures the one place Python's ordering was observable.
316        let currency_forms: HashMap<&'static str, CurrencyForms> = [
317            ("DKK", &["króna", "krónur"][..], &["oyra", "oyru"][..]),
318            ("USD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
319            ("EUR", &["euro", "euros"][..], &["cent", "cents"][..]),
320        ]
321        .into_iter()
322        .map(|(k, u, s)| (k, CurrencyForms::new(u, s)))
323        .collect();
324        LangFo { currency_forms }
325    }
326
327    /// The `(left, right)` split at the head of `Num2Word_FO.to_currency`, for
328    /// a value already made non-negative.
329    ///
330    /// Python works on the *string*:
331    ///
332    /// ```python
333    /// parts = str(val).split(".")
334    /// left = int(parts[0]) if parts[0] else 0
335    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
336    /// ```
337    ///
338    /// Taking the first two fractional digits and right-padding to two is
339    /// exactly `trunc(frac * 100)` for any plain decimal string — `"5"` →
340    /// `"50"` → 50 is `trunc(0.5 * 100)`, `"345"` → `"34"` is
341    /// `trunc(0.345 * 100)` — so this computes the split arithmetically rather
342    /// than round-tripping the value back through `BigDecimal`'s `Display`.
343    /// Identical results, one less dependency on a formatting impl we do not
344    /// control. (`lang_eu.rs` ports the same idiom the same way.)
345    ///
346    /// A true `int` has no "." in `str(val)`, so `len(parts) > 1` is false and
347    /// `right` is 0 — which is also what `frac == 0` gives here. FO therefore
348    /// needs no `isinstance(val, int)` branch: bug 11 means the int/float
349    /// distinction is invisible in the output.
350    fn split_currency(val: &CurrencyValue) -> (BigInt, BigInt) {
351        match val {
352            CurrencyValue::Int(i) => (i.abs(), BigInt::zero()),
353            CurrencyValue::Decimal { value: d, .. } => {
354                let abs = d.abs();
355                // int(parts[0]) — truncation, and abs is non-negative, so this
356                // is floor as well.
357                let left = abs.with_scale(0).as_bigint_and_exponent().0;
358                let frac = &abs - BigDecimal::from(left.clone());
359                let right = (frac * BigDecimal::from(CENT_DIVISOR))
360                    .with_scale(0)
361                    .as_bigint_and_exponent()
362                    .0;
363                (left, right)
364            }
365        }
366    }
367
368    /// Port of `Num2Word_FO._int_to_word`.
369    ///
370    /// Infallible: mirrors Python exactly, and Python cannot raise here. Every
371    /// `ONES`/`TENS` index is bounded by the enclosing range check (a value
372    /// `< 100` divided by 10 is `< 10`), so the `to_usize` casts below are
373    /// proven safe rather than assumed — the BigInt is only narrowed once the
374    /// branch has established it fits.
375    fn int_to_word(&self, number: &BigInt) -> String {
376        // Python: `return self.ones[0] if self.ones[0] else "zero"`.
377        // ONES[0] is "" → falsy → the fallback always wins. The condition is
378        // kept verbatim rather than folded to "zero" to document the dead arm.
379        if number.is_zero() {
380            return if ONES[0].is_empty() {
381                "zero".to_string()
382            } else {
383                ONES[0].to_string()
384            };
385        }
386
387        // Unreachable from `to_cardinal`, which strips the sign before calling
388        // in. Kept because Python has it: it would double the negword prefix.
389        if number.is_negative() {
390            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
391        }
392
393        if *number < BigInt::from(10) {
394            // 1..=9; ONES[0] is unreachable (the is_zero arm caught it).
395            return ONES[number.to_usize().expect("< 10")].to_string();
396        }
397
398        if *number < BigInt::from(100) {
399            let (tens_val, ones_val) = number.div_mod_floor(&BigInt::from(10));
400            let tens_word = TENS[tens_val.to_usize().expect("< 10")];
401            if ones_val.is_zero() {
402                return tens_word.to_string();
403            }
404            return format!("{} {}", tens_word, ONES[ones_val.to_usize().expect("< 10")]);
405        }
406
407        if *number < BigInt::from(1000) {
408            let (hundreds_val, remainder) = number.div_mod_floor(&BigInt::from(100));
409            // Python indexes `self.ones` directly here rather than recursing,
410            // which is why 100 is "ein hundrað" and not "hundrað".
411            let mut result = format!("{} {}", ONES[hundreds_val.to_usize().expect("< 10")], HUNDRED);
412            if !remainder.is_zero() {
413                result.push(' ');
414                result.push_str(&self.int_to_word(&remainder));
415            }
416            return result;
417        }
418
419        if *number < BigInt::from(1_000_000) {
420            let (thousands_val, remainder) = number.div_mod_floor(&BigInt::from(1000));
421            let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
422            if !remainder.is_zero() {
423                result.push(' ');
424                result.push_str(&self.int_to_word(&remainder));
425            }
426            return result;
427        }
428
429        if *number < BigInt::from(1_000_000_000) {
430            let (millions_val, remainder) = number.div_mod_floor(&BigInt::from(1_000_000));
431            let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
432            if !remainder.is_zero() {
433                result.push(' ');
434                result.push_str(&self.int_to_word(&remainder));
435            }
436            return result;
437        }
438
439        // Python: `return str(number)  # Fallback for very large numbers`.
440        // Digits, verbatim, no words and no exception. See bug 3.
441        number.to_string()
442    }
443}
444
445impl Default for LangFo {
446    fn default() -> Self {
447        Self::new()
448    }
449}
450
451impl Lang for LangFo {
452    /// This language's own `to_currency(currency=...)` default,
453    /// read from the live Python signature. Only 44 of 156 use EUR.
454    fn default_currency(&self) -> &str {
455        "DKK"
456    }
457
458    /// This language's own `to_currency(separator=...)` default,
459    /// read from the live Python signature. Base's is ",", but only
460    /// 36 of 149 languages actually use it — most default to " " or a
461    /// conjunction, so inheriting Base's comma silently corrupts them.
462    fn default_separator(&self) -> &str {
463        " "
464    }
465
466    fn negword(&self) -> &str {
467        NEGWORD
468    }
469
470    fn pointword(&self) -> &str {
471        "point"
472    }
473
474    /// Port of `Num2Word_FO.to_cardinal`, integer path only.
475    ///
476    /// Python stringifies the input, peels a leading "-" off the *string*, and
477    /// re-parses with `int()`; `str(int)` never contains ".", so integers
478    /// always take the `else` branch. Peeling the sign textually and re-parsing
479    /// is exactly `abs()` for every BigInt, so that is what we do.
480    ///
481    /// The trailing `.strip()` is what removes the space inside `negword`
482    /// ("minus ") from a bare-negword result; for the shapes reachable here it
483    /// only ever trims the seam, since `int_to_word` never returns padding.
484    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
485        let (ret, n) = if value.is_negative() {
486            (NEGWORD, value.abs())
487        } else {
488            ("", value.clone())
489        };
490        Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
491    }
492
493    /// Port of `Num2Word_FO.to_ordinal`: cardinal + a literal "-ti", with no
494    /// `verify_ordinal` call and no inspection of what came back. See bugs
495    /// 4, 5 and 6.
496    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
497        Ok(format!("{}-ti", self.to_cardinal(value)?))
498    }
499
500    /// Port of `Num2Word_FO.to_ordinal_num`: `str(number) + "."`. Note this
501    /// overrides `Num2Word_Base.to_ordinal_num` (which returns the value
502    /// untouched), and that it keeps the minus sign: -1 → "-1.".
503    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
504        Ok(format!("{}.", value))
505    }
506
507    /// Port of `Num2Word_FO.to_year`. Python overrides the base method but the
508    /// body is just `return self.to_cardinal(val)`; the `longval=True`
509    /// parameter is accepted and never read. So 1900 → "ein túsund níggju
510    /// hundrað" (not a century split) and -44 → "minus fjøruti fýra" (not
511    /// "fjøruti fýra f.Kr."). Stated explicitly rather than left to the trait
512    /// default, since Python does override it.
513    fn to_year(&self, value: &BigInt) -> Result<String> {
514        self.to_cardinal(value)
515    }
516
517    /// Port of the float/Decimal path of `Num2Word_FO.to_cardinal`.
518    ///
519    /// FO does **not** inherit `Num2Word_Base.to_cardinal_float`/`float2tuple`.
520    /// It overrides `to_cardinal(number)`, which operates on `str(number)`:
521    ///
522    /// ```python
523    /// n = str(number).strip()
524    /// if n.startswith("-"):
525    ///     n = n[1:]; ret = self.negword
526    /// else:
527    ///     ret = ""
528    /// if "." in n:
529    ///     left, right = n.split(".", 1)
530    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
531    ///     for digit in right:
532    ///         ret += self._int_to_word(int(digit)) + " "
533    ///     return ret.strip()
534    /// ```
535    ///
536    /// The fractional part is read **digit by digit off the decimal string**,
537    /// so the `float2tuple` f64-artefact heuristic never applies here: `2.675`
538    /// becomes `str` `"2.675"` → `"6 7 5"` directly, with no `674.999…` to
539    /// rescue. Reproduce that by rebuilding the repr string, not by arithmetic
540    /// on the raw double (which *would* reintroduce the artefact).
541    ///
542    /// * **Float** — Rust's `{}` drops the `.0` on whole floats (`1.0` → "1"),
543    ///   so the repr is reconstructed with `{:.precision$}` on the absolute
544    ///   value. `precision` is the repr-derived fractional length (== Python's
545    ///   `abs(Decimal(str(v)).as_tuple().exponent)`), and formatting the double
546    ///   back to that many places recovers exactly the shortest-round-trip
547    ///   digits.
548    /// * **Decimal** — reconstructed arithmetically (exact): `int(left)` is the
549    ///   truncated integer part, and the fractional digits are
550    ///   `(abs - left) * 10**precision` left-padded to `precision`. This keeps
551    ///   trailing zeros the way `str(Decimal("1.10"))` does ("1.10" → "10").
552    ///
553    /// `precision_override` (the `precision=` kwarg, issue #580) is **inert**
554    /// for FO: `to_cardinal` takes no such parameter, so the kwarg is dropped
555    /// before it can matter. Verified live:
556    /// `num2words(0.5, lang="fo", precision=3)` == "zero point fimm".
557    fn to_cardinal_float(
558        &self,
559        value: &FloatValue,
560        _precision_override: Option<u32>,
561    ) -> Result<String> {
562        // Rebuild `n` (already sign-stripped) as "<left>.<right>" with exactly
563        // `precision` fractional digits — or no "." when precision is 0, which
564        // mirrors Python's `if "." in n` guard falling through to the integer
565        // branch. `neg` mirrors `str(number).startswith("-")`.
566        let (neg, left, right_str) = match value {
567            FloatValue::Float { value, precision } => {
568                // Sign *bit*, not `< 0.0`: repr(-0.0) is "-0.0", so Python's
569                // startswith("-") is true and FO answers "minus zero point
570                // zero". A `< 0.0` test would drop the minus.
571                let neg = value.is_sign_negative();
572                let prec = *precision as usize;
573                let s = format!("{:.p$}", value.abs(), p = prec);
574                match s.split_once('.') {
575                    Some((l, r)) => (neg, l.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero()), r.to_string()),
576                    None => (neg, s.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero()), String::new()),
577                }
578            }
579            FloatValue::Decimal { value, precision } => {
580                let neg = value.is_negative();
581                let abs = value.abs();
582                // int(left) — truncate the absolute value toward zero.
583                let left = abs.with_scale(0).as_bigint_and_exponent().0;
584                if *precision == 0 {
585                    (neg, left, String::new())
586                } else {
587                    let frac = &abs - BigDecimal::from(left.clone());
588                    let scale = BigInt::from(10).pow(*precision);
589                    let right = (frac * BigDecimal::from(scale))
590                        .with_scale(0)
591                        .as_bigint_and_exponent()
592                        .0;
593                    // Left-pad to `precision` digits (right is non-negative and
594                    // < 10**precision, so this never truncates). Manual pad
595                    // rather than a numeric fill so BigInt's Display is used
596                    // verbatim.
597                    let rs = right.to_string();
598                    let pad = (*precision as usize).saturating_sub(rs.len());
599                    (neg, left, format!("{}{}", "0".repeat(pad), rs))
600                }
601            }
602        };
603
604        // ret += _int_to_word(int(left)) [+ " point " + per-digit words];
605        // strip. `pointword` is used raw — FO applies no title() here.
606        let mut tokens: Vec<String> = vec![self.int_to_word(&left)];
607        if !right_str.is_empty() {
608            tokens.push(POINTWORD.to_string());
609            for ch in right_str.chars() {
610                // Each char is one decimal digit; int(digit) ∈ 0..=9.
611                let d = ch.to_digit(10).unwrap_or(0);
612                tokens.push(self.int_to_word(&BigInt::from(d)));
613            }
614        }
615        let joined = tokens.join(" ");
616        // negword ("minus ") is prepended raw, then the whole thing stripped —
617        // exactly Python's `ret = self.negword + ...; return ret.strip()`.
618        let result = if neg { format!("{}{}", NEGWORD, joined) } else { joined };
619        Ok(result.trim().to_string())
620    }
621
622    /// `to_cardinal(float/Decimal)` — the full entry, routing on
623    /// `"." in str(number)` rather than on whole-ness (Base's
624    /// `int(value) == value` test never runs; FO overrides `to_cardinal`
625    /// outright).
626    ///
627    /// A whole float therefore keeps its ".0" tail (`5.0` -> "fimm point
628    /// zero", `-0.0` -> "minus zero point zero"), a point-less integral
629    /// Decimal takes the integer grammar (`Decimal("100")` -> "ein hundrað"),
630    /// and a point-less non-integer form is Python's `int()` ValueError
631    /// (`1e+16`, `Decimal("1E+2")`).
632    ///
633    /// `precision_override` is threaded through untouched; FO's grammar never
634    /// reads a precision, so it is inert either way (see `to_cardinal_float`).
635    fn cardinal_float_entry(
636        &self,
637        value: &FloatValue,
638        precision_override: Option<u32>,
639    ) -> Result<String> {
640        match route_by_str(value)? {
641            StrRoute::Point => self.to_cardinal_float(value, precision_override),
642            StrRoute::WholeDigits(abs_int) => {
643                // `(ret + self._int_to_word(int(n))).strip()`, with the sign
644                // already peeled off the string into `ret = self.negword`.
645                let body = self.int_to_word(&abs_int);
646                if value.is_negative() {
647                    Ok(format!("{}{}", NEGWORD, body).trim().to_string())
648                } else {
649                    Ok(body)
650                }
651            }
652        }
653    }
654
655    /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-ti"` — the
656    /// same blind suffix as the integer path (bugs 4/5/6), so `5.0` ==
657    /// "fimm point zero-ti" and the ValueError of `1e+16` propagates
658    /// unchanged.
659    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
660        Ok(format!("{}-ti", self.cardinal_float_entry(value, None)?))
661    }
662
663    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. Purely textual —
664    /// nothing is parsed, so even the scientific forms succeed: `1e+16` ==
665    /// "1e+16." and `Decimal("1E+2")` == "1E+2.".
666    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
667        Ok(format!("{}.", repr_str))
668    }
669
670    // `year_float_entry` is deliberately NOT overridden: FO's `to_year` is
671    // `return self.to_cardinal(val)`, and the trait default routes through
672    // the overridden `cardinal_float_entry` above — so `to_year(5.0)` ==
673    // "fimm point zero" and `to_year(1e+16)` raises ValueError, as the
674    // corpus pins.
675
676    /// `converter.str_to_number` — Base's `Decimal(value)`, which FO does not
677    /// override. `Decimal("Infinity")`/`"NaN"` parse fine; the ValueError FO
678    /// shows happens *later*, inside its own `to_cardinal`, which is now
679    /// served natively by [`Lang::inf_result`] / [`Lang::nan_result`] rather
680    /// than by a Python fallback.
681    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
682        python_decimal_parse(s)
683    }
684
685    /// `Decimal('Infinity')` / `-Infinity` per mode. FO's `to_cardinal`
686    /// re-parses the sign-stripped `str(number)` with `int()`, so
687    /// `int("Infinity")` raises `ValueError` for cardinal/ordinal/year (the
688    /// leading "-" of "-Infinity" is peeled off the string first, so the
689    /// message names 'Infinity' either way). `to_ordinal_num` never parses —
690    /// it is `str(number) + "."` — so it succeeds: "Infinity." / "-Infinity.".
691    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
692        match to {
693            "ordinal_num" => Ok(format!("{}Infinity.", if negative { "-" } else { "" })),
694            _ => Err(int_value_error("Infinity")),
695        }
696    }
697
698    /// `Decimal('NaN')` per mode. `int("NaN")` raises `ValueError` for
699    /// cardinal/ordinal/year; `to_ordinal_num` returns "NaN." unparsed.
700    fn nan_result(&self, to: &str) -> Result<String> {
701        match to {
702            "ordinal_num" => Ok("NaN.".to_string()),
703            _ => Err(int_value_error("NaN")),
704        }
705    }
706
707    // ---- currency ----------------------------------------------------
708
709    fn lang_name(&self) -> &str {
710        LANG_NAME
711    }
712
713    /// `CURRENCY_FORMS[code]`, **strictly** — `None` for anything else.
714    ///
715    /// Deliberately *not* the DKK-defaulting lookup FO's `to_currency` uses
716    /// (bug 8). This hook exists to serve the inherited
717    /// `Num2Word_Base.to_cheque`, which subscripts
718    /// `self.CURRENCY_FORMS[currency]` and turns the `KeyError` into
719    /// `NotImplementedError`. The fallback belongs to `to_currency` alone and
720    /// lives there; leaking it here would make `cheque:GBP` return
721    /// "... KRÓNUR" instead of raising, contradicting the corpus.
722    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
723        self.currency_forms.get(code)
724    }
725
726    /// Port of `Num2Word_FO.to_currency`.
727    ///
728    /// ```python
729    /// def to_currency(self, val, currency="DKK", cents=True,
730    ///                 separator=" ", adjective=False):
731    ///     is_negative = False
732    ///     if val < 0:
733    ///         is_negative = True
734    ///         val = abs(val)
735    ///
736    ///     parts = str(val).split(".")
737    ///     left = int(parts[0]) if parts[0] else 0
738    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
739    ///
740    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
741    ///         currency, list(self.CURRENCY_FORMS.values())[0]
742    ///     )
743    ///
744    ///     left_str = self._int_to_word(left)
745    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
746    ///
747    ///     if cents and right:
748    ///         cents_str = self._int_to_word(right)
749    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
750    ///
751    ///     if is_negative:
752    ///         result = self.negword + result
753    ///
754    ///     return result.strip()
755    /// ```
756    ///
757    /// A complete override: `Num2Word_Base.to_currency` is never called, so
758    /// none of `parse_currency_parts`, `pluralize`, `_cents_verbose`,
759    /// `_cents_terse`, `CURRENCY_PRECISION` or `CURRENCY_ADJECTIVES` is
760    /// reachable from here. Infallible — there is no `NotImplementedError`
761    /// path (bug 8) and no table index that can escape its range.
762    ///
763    /// Note the plural is selected by `left != 1` / `right != 1` directly
764    /// rather than by `pluralize`, which `Num2Word_FO` leaves abstract.
765    fn to_currency(
766        &self,
767        val: &CurrencyValue,
768        currency: &str,
769        cents: bool,
770        separator: Option<&str>,
771        _adjective: bool, // declared by Python, never read — bug 10.
772    ) -> Result<String> {
773        // Trait now hands us None when the caller omitted separator=;
774        // resolve it to this language's own default before the ported body.
775        let separator = separator.unwrap_or(self.default_separator());
776        let separator = if separator == SEPARATOR_UNSET {
777            SEPARATOR_DEFAULT
778        } else {
779            separator
780        };
781
782        // Python: `if val < 0: is_negative = True; val = abs(val)`. The abs is
783        // folded into split_currency, which every later step reads through.
784        let is_negative = val.is_negative();
785        let (left, right) = Self::split_currency(val);
786
787        // `.get(currency, list(CURRENCY_FORMS.values())[0])` — bug 8. Python
788        // evaluates the default eagerly on every call, but it cannot fail:
789        // DKK is always present.
790        let forms = match self.currency_forms.get(currency) {
791            Some(f) => f,
792            None => &self.currency_forms[FALLBACK_CURRENCY],
793        };
794
795        let one = BigInt::one();
796        let left_str = self.int_to_word(&left);
797        let mut result = format!(
798            "{} {}",
799            left_str,
800            if left != one { &forms.unit[1] } else { &forms.unit[0] }
801        );
802
803        // `if cents and right:` — a truthiness test on the cent count, not on
804        // the type of `val`. See bug 11.
805        if cents && !right.is_zero() {
806            let cents_str = self.int_to_word(&right);
807            result.push_str(separator);
808            result.push_str(&cents_str);
809            result.push(' ');
810            result.push_str(if right != one {
811                &forms.subunit[1]
812            } else {
813                &forms.subunit[0]
814            });
815        }
816
817        if is_negative {
818            // negword carries a trailing space ("minus "), so this is the seam
819            // the final strip() does *not* touch.
820            result = format!("{}{}", NEGWORD, result);
821        }
822
823        Ok(result.trim().to_string())
824    }
825}