Skip to main content

num2words2_core/
lang_fil.rs

1//! Port of `lang_FIL.py` (Filipino / Tagalog).
2//!
3//! Registry check: `__init__.py` maps `"fil" -> lang_FIL.Num2Word_FIL()`, which
4//! is the class ported here.
5//!
6//! Shape: **self-contained**. `Num2Word_FIL` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
8//! `hasattr(...)` guard in `Num2Word_Base.__init__` never fires: `self.cards`
9//! is never built and `self.MAXVAL` is never set. All four in-scope methods
10//! (`to_cardinal`, `to_ordinal`, `to_ordinal_num`, `to_year`) are overridden
11//! outright, so nothing inherited is reachable and `cards`/`maxval`/`merge`
12//! stay at their trait defaults. There is **no overflow check** — see bug 1.
13//!
14//! `setup()` sets `exclude_title = ["at", "menos", "punto"]`, but `is_title`
15//! is left at the `False` that `Num2Word_Base.__init__` assigns *before*
16//! calling `setup()`, and FIL's `to_cardinal` never calls `title()` anyway.
17//! Both are mirrored here for fidelity; neither affects output.
18//!
19//! # Faithfully reproduced Python bugs
20//!
21//! This is a port, not a rewrite. Everything below looks wrong and is exactly
22//! what Python emits — all of it is pinned by rows in `bench/corpus.jsonl`.
23//!
24//! 1. **`_int_to_word` gives up at 10^9 and returns the digits.** The final
25//!    `return str(number)` is a fallthrough, not a raise: FIL has no numword
26//!    above `milyon`. So `to_cardinal(10**9) == "1000000000"` and
27//!    `to_cardinal(10**21) == "1000000000000000000000"` — the bare decimal
28//!    string, no words at all. This propagates: `to_ordinal(10**9)` is
29//!    `"ika-1000000000"`, and negatives compose to e.g.
30//!    `"menos 1000000000"`. Because the value is stringified rather than
31//!    decomposed, the input is unbounded and must stay `BigInt`.
32//! 2. **`" at "` is used as a flat conjunction at every level**, so hundreds
33//!    and tens are both joined with it: `999` is
34//!    `"siyam daan at siyamnapu at siyam"` (two "at"s). Idiomatic Filipino
35//!    would contract to "siyam na raan at siyamnapu't siyam"; the port keeps
36//!    the Python.
37//! 3. **No ligature/linker particles.** `ones[h] + " " + hundred` yields
38//!    `"isa daan"` for 100 and `"dalawa daan"` for 200, where Filipino wants
39//!    "isang daan" / "dalawang daan". Likewise `"isa libo"` (1000) and
40//!    `"isa milyon"` (10^6). Preserved verbatim.
41//! 4. **Thousands/millions remainders are joined with a bare space, not
42//!    "at"** — unlike the tens/hundreds arms. Hence `1001` is
43//!    `"isa libo isa"` while `101` is `"isa daan at isa"`.
44//! 5. **`to_ordinal_num` does no sign handling**: it is literally
45//!    `"ika-" + str(number)`, so `to_ordinal_num(-1) == "ika--1"` (double
46//!    hyphen). Corpus-confirmed.
47//! 6. **`to_ordinal` special-cases only `1`**, so `to_ordinal(0)` is
48//!    `"ika-sero"` and `to_ordinal(-1)` is `"ika-menos isa"` — the negative
49//!    word ends up *inside* the ordinal prefix.
50//! 7. `tens[1] == "sampu"` is dead code: the `number < 20` teens arm catches
51//!    10..=19 before the `number < 100` arm can ever see `t == 1`. Kept in the
52//!    table so the indices line up with Python.
53//!
54//! # Currency
55//!
56//! `Num2Word_FIL` overrides `to_currency` **wholesale** and shares almost
57//! nothing with `Num2Word_Base.to_currency`, so the trait's
58//! `default_to_currency` is bypassed entirely. The differences are all
59//! load-bearing:
60//!
61//! * **An unknown currency code does not raise.** Python's
62//!   `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
63//!   falls back to the *first* entry — PHP — so `to_currency(1, "GBP")` is
64//!   `"isa piso"`, not a NotImplementedError. The corpus pins this for GBP,
65//!   JPY, KWD, BHD, INR, CNY and CHF (bug 8).
66//! * **`CURRENCY_PRECISION` is never consulted.** The `[:2]`/`ljust(2, "0")`
67//!   slice hardcodes 2 decimal places, so the 3-decimal (KWD/BHD) and
68//!   0-decimal (JPY) paths that `base.py` implements do not exist here:
69//!   `12.34 KWD` is "labindalawa piso tatlumpu at apat sentimo", the same
70//!   two-decimal reading as EUR (bug 9).
71//! * **`adjective` is accepted and ignored** — FIL has no
72//!   `CURRENCY_ADJECTIVES`, and the parameter is never read (bug 10).
73//! * **`pluralize` is not called.** `to_currency` indexes the form tuples
74//!   directly (`cr1[1] if left != 1 else cr1[0]`). FIL's `pluralize` override
75//!   exists but is unreachable — see its doc comment.
76//! * **The cents digits are truncated, not rounded.** `parts[1][:2]` drops
77//!   everything past the second decimal, so `1.239` yields 23 sentimo, where
78//!   `base.py`'s ROUND_HALF_UP quantize would give 24 (bug 11). No corpus row
79//!   reaches past two decimals.
80//!
81//! `to_cheque`, by contrast, is inherited from `Num2Word_Base` untouched, and
82//! it reads `CURRENCY_FORMS` with a **subscript** rather than `.get`. So the
83//! two entry points disagree about the same input: `cheque:GBP` raises
84//! NotImplementedError while `currency:GBP` silently prints pesos. Both are
85//! corpus-pinned. That is why `currency_forms()` below reports the honest
86//! 3-entry table (which is what `to_cheque` needs) and the PHP fallback lives
87//! inside `to_currency` (bug 12).
88//!
89//! # Errors
90//!
91//! No *integer* input raises, and neither does `to_currency`: FIL never
92//! indexes past a table (every lookup is guarded by the range check
93//! immediately above it), has no MAXVAL to overflow, and its currency lookup
94//! falls back rather than failing. The one raising path is the inherited
95//! `to_cheque` on a code outside `CURRENCY_FORMS`, and that
96//! `NotImplemented` is constructed by `currency::default_to_cheque` — so this
97//! module still never names an `N2WError` itself.
98
99use crate::base::{Lang, N2WError, Result};
100use crate::currency::{CurrencyForms, CurrencyValue};
101use crate::floatpath::{float2tuple, FloatValue};
102use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
103use bigdecimal::BigDecimal;
104use num_bigint::BigInt;
105use num_integer::Integer;
106use num_traits::{One, Signed, ToPrimitive, Zero};
107use std::collections::HashMap;
108
109/// `self.negword` — note the **trailing space**, which Python relies on when
110/// it does `self.negword + self.to_cardinal(...)` (no separator of its own).
111const NEGWORD: &str = "menos ";
112/// `self.pointword`. Only reachable on the float path, which is out of scope.
113const POINTWORD: &str = "punto";
114
115/// `self.ones`. Index 0 is `""` and unreachable via `_int_to_word` (the
116/// `number == 0` guard returns "sero" first); it is only ever read as
117/// `ones[0]` on the float/decimal path, where Python's `or "sero"` rescues it.
118const ONES: [&str; 10] = [
119    "", "isa", "dalawa", "tatlo", "apat", "lima", "anim", "pito", "walo", "siyam",
120];
121
122/// `self.teens`, keyed by `number - 10` for 10..=19.
123const TEENS: [&str; 10] = [
124    "sampu",
125    "labing-isa",
126    "labindalawa",
127    "labintatlo",
128    "labing-apat",
129    "labinlima",
130    "labing-anim",
131    "labimpito",
132    "labingwalo",
133    "labinsiyam",
134];
135
136/// `self.tens`, keyed by the tens digit. Index 0 is `""` (unreachable: the
137/// `number < 20` arm fires first), index 1 is dead — see bug 7.
138const TENS: [&str; 10] = [
139    "",
140    "sampu",
141    "dalawampu",
142    "tatlumpu",
143    "apatnapu",
144    "limampu",
145    "animnapu",
146    "pitumpu",
147    "walumpu",
148    "siyamnapu",
149];
150
151const HUNDRED: &str = "daan";
152const THOUSAND: &str = "libo";
153const MILLION: &str = "milyon";
154
155/// The key `list(self.CURRENCY_FORMS.values())[0]` resolves to.
156///
157/// Python dicts preserve insertion order and `"PHP"` is the first key in
158/// `Num2Word_FIL`'s class-body literal, so that expression is always PHP's
159/// entry. A `HashMap` has no order, so the identity is pinned here rather
160/// than derived — reordering `build_currency_forms` must not change it.
161const FALLBACK_CURRENCY: &str = "PHP";
162
163/// `Num2Word_FIL.CURRENCY_FORMS`, verbatim.
164///
165/// FIL declares the dict in its own class body and no other class writes into
166/// it, so — unlike the `Num2Word_EUR` table that `Num2Word_EN.__init__`
167/// mutates in place at import time — the source literal *is* what runs.
168/// Checked against the live interpreter, not just the file.
169///
170/// Every entry is a 2-tuple whose two forms are **identical** ("piso"/"piso").
171/// Filipino does not inflect these nouns for number, so the singular/plural
172/// branch in `to_currency` can never change the output. The pairs are kept at
173/// arity 2 anyway: `to_currency` indexes `[1]` unconditionally when
174/// `left != 1`, and collapsing them to one form would turn that into an
175/// IndexError.
176fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
177    const SENTIMO: [&str; 2] = ["sentimo", "sentimo"];
178
179    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
180    // Insertion order matches the Python literal for readability only; the
181    // PHP fallback is keyed by name (see FALLBACK_CURRENCY), not by position.
182    m.insert("PHP", CurrencyForms::new(&["piso", "piso"], &SENTIMO));
183    m.insert("USD", CurrencyForms::new(&["dolyar", "dolyar"], &SENTIMO));
184    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &SENTIMO));
185    m
186}
187
188/// Narrow a table index to `usize`.
189///
190/// Every call site sits behind a `number < 10` / `< 100` / `< 1000` guard and
191/// operates on a value `to_cardinal` has already made non-negative, so the
192/// operand is provably in `0..=999` and the conversion cannot fail. This is
193/// the only place a `BigInt` is narrowed; the value itself is never cast (see
194/// bug 1 — inputs are unbounded).
195fn idx(n: &BigInt) -> usize {
196    debug_assert!(!n.is_negative() && n < &BigInt::from(1000));
197    n.to_usize()
198        .expect("guarded to 0..=999 by the range check at the call site")
199}
200
201pub struct LangFil {
202    /// `self.exclude_title`. Inert: `is_title` is false and FIL's `to_cardinal`
203    /// never calls `title()`. Carried for fidelity with `setup()`.
204    exclude_title: Vec<String>,
205    /// `self.CURRENCY_FORMS`. Built once here, never per call.
206    currency_forms: HashMap<&'static str, CurrencyForms>,
207}
208
209impl Default for LangFil {
210    fn default() -> Self {
211        Self::new()
212    }
213}
214
215impl LangFil {
216    pub fn new() -> Self {
217        LangFil {
218            exclude_title: vec!["at".into(), "menos".into(), "punto".into()],
219            currency_forms: build_currency_forms(),
220        }
221    }
222
223    /// `parts = str(val).split(".")` -> `(left, right)`, with `val` already
224    /// non-negative.
225    ///
226    /// Python slices the *decimal string*:
227    ///
228    /// ```python
229    /// left  = int(parts[0]) if parts[0] else 0
230    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
231    /// ```
232    ///
233    /// Done here as arithmetic on the `BigDecimal` instead of re-deriving
234    /// Python's `str()`. The two agree exactly for every plain decimal string,
235    /// which is all the shim can produce for the values that reach here:
236    ///
237    /// * `left` is the integer part. `parts[0]` holds precisely the digits
238    ///   before the point, and `int()` of them is the truncation of a
239    ///   non-negative value — `with_scale(0)`.
240    /// * `right` is `trunc(frac * 100)`. `[:2]` keeps the first two fractional
241    ///   digits and `ljust(2, "0")` right-pads a lone digit, so `"5"` -> `"50"`
242    ///   -> 50, exactly `trunc(0.5 * 100)`. `"239"` -> `"23"` -> 23, exactly
243    ///   `trunc(0.239 * 100)` — Python truncates the third decimal away rather
244    ///   than rounding it (bug 11), and `with_scale(0)` truncates to match.
245    /// * Python's two emptiness guards are unreachable: `str()` of a
246    ///   non-negative number never yields an empty `parts[0]` (`"0.5"`, never
247    ///   `".5"`) and never a trailing bare `"."`. Both fall out of the
248    ///   arithmetic as 0 regardless.
249    ///
250    /// The one input the arithmetic would *not* match is a value whose
251    /// `str()` is in exponent form (`str(1e21) == "1e+21"`), where Python's
252    /// `int("1e+21")` raises ValueError while `BigDecimal` parses it happily.
253    /// See `concerns` — no corpus row reaches it.
254    fn currency_parts(val: &CurrencyValue) -> (BigInt, BigInt) {
255        match val {
256            // `str(int)` has no ".", so `len(parts) == 1` and right is 0.
257            // This is the only thing the Int/Decimal split changes for FIL:
258            // unlike `base.to_currency`, nothing here tests `isinstance`.
259            CurrencyValue::Int(i) => (i.abs(), BigInt::zero()),
260            // `has_decimal` is deliberately ignored: Python reads only the
261            // shape of `str(val)`, so `Decimal("5")` and `Decimal("5.00")`
262            // both land on right == 0 and print no cents either way.
263            CurrencyValue::Decimal { value, .. } => {
264                let abs = value.abs();
265                let left = abs.with_scale(0).as_bigint_and_exponent().0;
266                let frac = &abs - BigDecimal::from(left.clone());
267                let right = (frac * BigDecimal::from(100))
268                    .with_scale(0)
269                    .as_bigint_and_exponent()
270                    .0;
271                (left, right)
272            }
273        }
274    }
275
276    /// Port of `Num2Word_FIL._int_to_word`.
277    ///
278    /// Only ever reached with a non-negative value: `to_cardinal` detaches the
279    /// `"-"` from the *string* before parsing, so the recursive call always
280    /// sees a bare magnitude. (Were a negative to reach Python here, `< 10`
281    /// would be true and `self.ones[-5]` would silently negative-index to
282    /// "lima" — unreachable from any in-scope entry point, so not modelled.)
283    ///
284    /// `div_mod_floor` rather than `%` keeps Python's divmod semantics; for
285    /// the non-negative operands here the two agree, but the floor form is
286    /// what `divmod` means.
287    fn int_to_word(&self, number: &BigInt) -> String {
288        if number.is_zero() {
289            return "sero".to_string();
290        }
291        if number < &BigInt::from(10) {
292            return ONES[idx(number)].to_string();
293        }
294        if number < &BigInt::from(20) {
295            return TEENS[idx(&(number - BigInt::from(10)))].to_string();
296        }
297        if number < &BigInt::from(100) {
298            // t, o = divmod(number, 10)
299            let (t, o) = number.div_mod_floor(&BigInt::from(10));
300            let mut s = TENS[idx(&t)].to_string();
301            if !o.is_zero() {
302                s.push_str(" at ");
303                s.push_str(ONES[idx(&o)]);
304            }
305            return s;
306        }
307        if number < &BigInt::from(1000) {
308            // h, r = divmod(number, 100); base = ones[h] + " " + hundred
309            let (h, r) = number.div_mod_floor(&BigInt::from(100));
310            let mut s = format!("{} {}", ONES[idx(&h)], HUNDRED);
311            if !r.is_zero() {
312                // "at" here, but a bare space in the two arms below — bug 4.
313                s.push_str(" at ");
314                s.push_str(&self.int_to_word(&r));
315            }
316            return s;
317        }
318        if number < &BigInt::from(1_000_000) {
319            // t, r = divmod(number, 1000)
320            let (t, r) = number.div_mod_floor(&BigInt::from(1000));
321            let mut s = format!("{} {}", self.int_to_word(&t), THOUSAND);
322            if !r.is_zero() {
323                s.push(' ');
324                s.push_str(&self.int_to_word(&r));
325            }
326            return s;
327        }
328        if number < &BigInt::from(1_000_000_000) {
329            // m, r = divmod(number, 1000000)
330            let (m, r) = number.div_mod_floor(&BigInt::from(1_000_000));
331            let mut s = format!("{} {}", self.int_to_word(&m), MILLION);
332            if !r.is_zero() {
333                s.push(' ');
334                s.push_str(&self.int_to_word(&r));
335            }
336            return s;
337        }
338        // `return str(number)` — bug 1. Not an error: the digits are the output.
339        number.to_string()
340    }
341}
342
343/// Python `repr()` of a **non-negative** f64 — the string FIL's `to_cardinal`
344/// sees after the leading `"-"` is detached. Rust's `{}` produces the same
345/// shortest-round-trip digits but never switches to exponent form and drops
346/// the ".0" of whole values, so the two Python-isms are reapplied:
347///
348/// * exponent form for `|v| >= 1e16` or `0 < |v| < 1e-4`, sign always shown,
349///   exponent zero-padded to two digits (`"1e+16"`, `"1.5e-05"`);
350/// * a trailing `".0"` for whole values in positional form (`"5.0"`).
351///
352/// inf/nan come back as `"inf"`/`"nan"`, which the `int()` port rejects with
353/// Python's ValueError.
354fn python_float_repr_abs(f: f64) -> String {
355    if f.is_nan() {
356        return "nan".to_string();
357    }
358    if f.is_infinite() {
359        return "inf".to_string();
360    }
361    if f != 0.0 && (f >= 1e16 || f < 1e-4) {
362        let s = format!("{:e}", f);
363        let (mant, exp) = s.split_once('e').expect("LowerExp always emits an e");
364        let exp: i32 = exp.parse().expect("f64 exponent is a small integer");
365        return format!(
366            "{}e{}{:02}",
367            mant,
368            if exp < 0 { '-' } else { '+' },
369            exp.abs()
370        );
371    }
372    let s = format!("{}", f);
373    if s.contains('.') {
374        s
375    } else {
376        format!("{}.0", s)
377    }
378}
379
380/// Python's `int()` ValueError, quoting the offending literal.
381fn int_value_error(lit: &str) -> N2WError {
382    N2WError::Value(format!(
383        "invalid literal for int() with base 10: '{}'",
384        lit
385    ))
386}
387
388impl Lang for LangFil {
389    /// This language's own `to_currency(currency=...)` default,
390    /// read from the live Python signature. Only 44 of 156 use EUR.
391    fn default_currency(&self) -> &str {
392        "PHP"
393    }
394
395    /// This language's own `to_currency(separator=...)` default,
396    /// read from the live Python signature. Base's is ",", but only
397    /// 36 of 149 languages actually use it — most default to " " or a
398    /// conjunction, so inheriting Base's comma silently corrupts them.
399    fn default_separator(&self) -> &str {
400        " "
401    }
402
403    fn negword(&self) -> &str {
404        NEGWORD
405    }
406
407    fn pointword(&self) -> &str {
408        "punto"
409    }
410
411    fn exclude_title(&self) -> &[String] {
412        &self.exclude_title
413    }
414
415    /// Port of `Num2Word_FIL.to_cardinal`, integer path only.
416    ///
417    /// Python works on `str(number).strip()` and branches on a leading `"-"`,
418    /// recursing on `n[1:]` — equivalent to `int_to_word(abs(value))` for
419    /// integer input, since `str(int)` is always a bare optional sign plus
420    /// digits. The `"." in n` branch (pointword + per-digit decimals) is
421    /// unreachable for integers and out of scope.
422    ///
423    /// The trailing `.strip()` is faithful but a no-op: `int_to_word` never
424    /// emits surrounding whitespace, and `NEGWORD`'s trailing space is
425    /// consumed by the concatenation.
426    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
427        if value.is_negative() {
428            let inner = self.int_to_word(&value.abs());
429            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
430        }
431        Ok(self.int_to_word(value))
432    }
433
434    /// Port of `Num2Word_FIL.to_ordinal`.
435    ///
436    /// ```python
437    /// if number == 1: return "una"
438    /// return "ika-" + self.to_cardinal(number)
439    /// ```
440    /// No sign or zero guard — see bug 6.
441    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
442        if value.is_one() {
443            return Ok("una".to_string());
444        }
445        Ok(format!("ika-{}", self.to_cardinal(value)?))
446    }
447
448    /// Port of `Num2Word_FIL.to_ordinal_num`: `"ika-" + str(number)`.
449    /// Negatives yield a double hyphen — see bug 5.
450    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
451        Ok(format!("ika-{}", value))
452    }
453
454    /// Port of `Num2Word_FIL.to_year`: `to_year(val, longval=True)` ignores
455    /// `longval` entirely and delegates straight to `to_cardinal`, so years
456    /// read as plain cardinals ("isa libo siyam daan at siyamnapu at siyam"
457    /// for 1999, not an English-style "nineteen ninety-nine" pairing).
458    /// Negative years get the cardinal's "menos" rather than a BC/AD suffix.
459    fn to_year(&self, value: &BigInt) -> Result<String> {
460        self.to_cardinal(value)
461    }
462
463    /// Port of `Num2Word_FIL.to_cardinal`'s **float/Decimal branch**.
464    ///
465    /// FIL overrides `to_cardinal` and handles non-integers inline, so a float
466    /// or Decimal never reaches `Num2Word_Base.to_cardinal_float`. Python:
467    ///
468    /// ```python
469    /// n = str(number).strip()
470    /// if n.startswith("-"):
471    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
472    /// if "." in n:
473    ///     left, right = n.split(".", 1)
474    ///     ret = self._int_to_word(int(left)) + " " + self.pointword
475    ///     for digit in right:
476    ///         ret += " " + (self.ones[int(digit)] or "sero")
477    ///     return ret.strip()
478    /// return self._int_to_word(int(n))
479    /// ```
480    ///
481    /// **Why FIL cannot inherit `default_to_cardinal_float`.** It reads
482    /// `str(number)`'s own fractional digits and never consults
483    /// `self.precision`, so the `precision=` kwarg is silently ignored:
484    /// `num2words(2.675, lang='fil', precision=1)` is still the full
485    /// "dalawa punto anim pito lima", where the base path truncates to one
486    /// digit. `precision_override` is therefore dropped here — the one real
487    /// behavioural difference from the default, invisible on the corpus (which
488    /// carries no `precision=`) but load-bearing.
489    ///
490    /// Digit reconstruction still goes through `float2tuple`, not a re-parse of
491    /// a decimal string: the f64 artefacts are load-bearing (`2.675` yields
492    /// `674.9999999999998`, rescued to `675` by the `< 0.01` heuristic), and
493    /// `str(number)`'s fractional part is exactly `post` left-padded to the
494    /// value's own precision. `pre.abs()` is `int(left)`; the sign is carried
495    /// by the `NEGWORD` prefix, mirroring Python's `str`-prefix recursion, so
496    /// e.g. `-0.5` (pre 0) becomes "menos sero punto lima".
497    ///
498    /// Each fractional digit uses `ones[d] or "sero"` verbatim — `ones[0]` is
499    /// the empty string, falsy in Python, hence "sero"; 1..=9 index the table.
500    fn to_cardinal_float(
501        &self,
502        value: &FloatValue,
503        // `precision=` is dropped: FIL never reads self.precision (see above).
504        _precision_override: Option<u32>,
505    ) -> Result<String> {
506        // Use the value's own repr-derived precision — the count of fractional
507        // digits str(number) shows, i.e. len(right) in the Python source.
508        let precision = value.precision();
509        let is_negative = value.is_negative();
510        let (pre, post) = float2tuple(value);
511        // int(left): the integer part of |value|; the sign is prefixed below.
512        let left = pre.abs();
513
514        // No "." in str(number) — an integer-valued Decimal like Decimal("5")
515        // (precision 0) — takes Python's `return self._int_to_word(int(n))`
516        // arm: the bare integer word, no "punto".
517        if precision == 0 {
518            let mut ret = self.int_to_word(&left);
519            if is_negative {
520                ret = format!("{}{}", NEGWORD, ret);
521            }
522            return Ok(ret.trim().to_string());
523        }
524
525        // right = str(number)'s fractional part = post left-padded to precision.
526        let post_str = post.to_string();
527        let post_str = format!(
528            "{}{}",
529            "0".repeat((precision as usize).saturating_sub(post_str.len())),
530            post_str
531        );
532
533        // ret = _int_to_word(int(left)) + " " + pointword
534        let mut ret = format!("{} {}", self.int_to_word(&left), POINTWORD);
535        // for digit in right: ret += " " + (ones[int(digit)] or "sero")
536        for ch in post_str.chars().take(precision as usize) {
537            let d = ch.to_digit(10).ok_or_else(|| {
538                N2WError::Value(format!("non-digit {:?} in fractional part", ch))
539            })?;
540            ret.push(' ');
541            ret.push_str(if d == 0 { "sero" } else { ONES[d as usize] });
542        }
543
544        if is_negative {
545            // Python: (self.negword + result).strip(); NEGWORD keeps its space.
546            ret = format!("{}{}", NEGWORD, ret);
547        }
548        // `.strip()` — inert: nothing above leaves surrounding whitespace.
549        Ok(ret.trim().to_string())
550    }
551
552    /// `to_cardinal(float/Decimal)` — the FULL routing, whole values included.
553    ///
554    /// FIL routes on the string, not the value: `"." in str(number)` decides
555    /// between the pointword grammar and `int(n)`. Three corpus-pinned
556    /// behaviours hang off the exact shape of `str()`:
557    ///
558    /// * **Whole floats keep their ".0"** — `str(5.0)` is `"5.0"`, so
559    ///   `to_cardinal(5.0)` == `"lima punto sero"`. Only a point-free string
560    ///   (an integer-valued `Decimal` like `Decimal("5")`) takes the int path.
561    /// * **Exponent-form reprs raise ValueError**: `str(1e16)` == `"1e+16"`
562    ///   (no "."), so `int("1e+16")` raises; `Decimal("1E+2")` likewise. A
563    ///   *dotted* e-form (`"1.5e+16"`) enters the `"."` branch and dies in the
564    ///   digit loop at `int('e')` (`'E'` for Decimals).
565    /// * **-0.0 keeps its sign** — `str(-0.0)` is `"-0.0"`, hence
566    ///   `"menos sero punto sero"`.
567    fn cardinal_float_entry(
568        &self,
569        value: &FloatValue,
570        precision_override: Option<u32>,
571    ) -> Result<String> {
572        // n = str(number).strip() minus its sign — Python detaches a leading
573        // "-" before any other test; the sign is re-applied from the value
574        // below (int path) or inside to_cardinal_float (dotted path).
575        let n = match value {
576            FloatValue::Float { value: f, .. } => python_float_repr_abs(f.abs()),
577            FloatValue::Decimal { value: d, .. } => {
578                let s = python_decimal_str(d);
579                s.strip_prefix('-').map(str::to_string).unwrap_or(s)
580            }
581        };
582
583        match n.split_once('.') {
584            Some((left, right)) => {
585                // `int(left)` runs before the digit loop; unreachable for any
586                // dotted str() in practice, mirrored for order fidelity.
587                if left.is_empty() || !left.bytes().all(|b| b.is_ascii_digit()) {
588                    return Err(int_value_error(left));
589                }
590                // `self.ones[int(digit)]` — the first non-digit raises; this
591                // is where "1.5e+16" / Decimal("1.5E+20") die.
592                if let Some(bad) = right.chars().find(|c| !c.is_ascii_digit()) {
593                    return Err(int_value_error(&bad.to_string()));
594                }
595                self.to_cardinal_float(value, precision_override)
596            }
597            None => {
598                if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
599                    return Err(int_value_error(&n));
600                }
601                let magnitude: BigInt = n.parse().expect("all-ASCII-digit string parses");
602                if value.is_negative() {
603                    // (self.negword + self.to_cardinal(n[1:])).strip()
604                    return Ok(format!("{}{}", NEGWORD, self.int_to_word(&magnitude))
605                        .trim()
606                        .to_string());
607                }
608                Ok(self.int_to_word(&magnitude))
609            }
610        }
611    }
612
613    /// `to_ordinal(float/Decimal)`. `number == 1` is a *numeric* comparison,
614    /// so `1.0` and `Decimal("1.00")` hit "una"; everything else is `"ika-"`
615    /// glued to the string-routed cardinal — `to_ordinal(5.0)` ==
616    /// `"ika-lima punto sero"`, `to_ordinal(-0.0)` == `"ika-menos sero punto
617    /// sero"` — and an exponent-form repr propagates the ValueError.
618    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
619        if value.as_whole_int() == Some(BigInt::from(1u32)) {
620            return Ok("una".to_string());
621        }
622        Ok(format!("ika-{}", self.cardinal_float_entry(value, None)?))
623    }
624
625    /// `to_ordinal_num(float/Decimal)`: `"ika-" + str(number)` — the raw
626    /// repr, never an error: `to_ordinal_num(-0.0)` == `"ika--0.0"`.
627    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
628        Ok(format!("ika-{}", repr_str))
629    }
630
631    /// `to_year(float/Decimal)`: bare `self.to_cardinal(val)`, string routing
632    /// included — `to_year(5.0)` == `"lima punto sero"`, `to_year(1e16)`
633    /// raises ValueError.
634    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
635        self.cardinal_float_entry(value, None)
636    }
637
638    /// `str_to_number` is inherited from the base (`Decimal(value)`) and this
639    /// override does not change what parses. It exists because
640    /// `Decimal("Infinity")`/`Decimal("NaN")` *do* parse in Python, and FIL's
641    /// `to_cardinal` then dies at `int("Infinity")` with ValueError — where
642    /// the bridge hard-wires the *base* integer-path errors (OverflowError /
643    /// "cannot convert NaN"), which FIL never produces. The one input this
644    /// would misserve is `to_ordinal_num("Infinity")` (Python:
645    /// `"ika-Infinity"`), which no corpus exercises.
646    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
647        match python_decimal_parse(s)? {
648            ParsedNumber::Inf { .. } => Err(N2WError::Value(
649                "invalid literal for int() with base 10: 'Infinity'".into(),
650            )),
651            ParsedNumber::NaN => Err(N2WError::Value(
652                "invalid literal for int() with base 10: 'NaN'".into(),
653            )),
654            other => Ok(other),
655        }
656    }
657
658    // ---- currency -------------------------------------------------------
659    //
660    // FIL overrides only `to_currency` and `pluralize`. `to_cheque`,
661    // `_money_verbose`, `_cents_verbose` and `_cents_terse` are inherited from
662    // `Num2Word_Base` unchanged, and the trait defaults already mirror them —
663    // `money_verbose` routing through `to_cardinal` is what gives the cheque
664    // path its Filipino words. `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES`
665    // are both Base's empty dict, so `.get(code, 100)` is a constant 100 and
666    // `.get(code)` is always None: the trait defaults are already exact and
667    // overriding them would only add noise.
668
669    fn lang_name(&self) -> &str {
670        "Num2Word_FIL"
671    }
672
673    /// `CURRENCY_FORMS[code]`, as the inherited `to_cheque` subscripts it.
674    ///
675    /// Reports None for anything outside the three declared codes, which is
676    /// what turns `cheque:GBP` into Python's KeyError-to-NotImplementedError.
677    /// `to_currency` deliberately does **not** route through this — it applies
678    /// the PHP fallback instead (bug 12).
679    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
680        self.currency_forms.get(code)
681    }
682
683    /// Port of `Num2Word_FIL.pluralize`.
684    ///
685    /// ```python
686    /// if not forms:
687    ///     return ""
688    /// return forms[0] if n == 1 else forms[-1]
689    /// ```
690    ///
691    /// Note `forms[-1]`, the *last* form — not `forms[1]` as `Num2Word_EUR`
692    /// uses. The distinction is invisible for FIL's own table (every entry is
693    /// a 2-tuple, where last == [1]) but it is what the class says, and the
694    /// empty-`forms` guard means this override cannot raise at all.
695    ///
696    /// Unreachable in practice: FIL's `to_currency` indexes the form tuples
697    /// itself and `Num2Word_Base.to_cheque` never calls `pluralize`. Ported
698    /// because the override exists — leaving it at the trait default would
699    /// raise NotImplemented on a path where Python returns a string.
700    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
701        if forms.is_empty() {
702            return Ok(String::new());
703        }
704        Ok(if n.is_one() {
705            forms[0].clone()
706        } else {
707            forms[forms.len() - 1].clone()
708        })
709    }
710
711    /// Port of `Num2Word_FIL.to_currency`.
712    ///
713    /// ```python
714    /// is_negative = val < 0
715    /// val = abs(val)
716    /// parts = str(val).split(".")
717    /// left  = int(parts[0]) if parts[0] else 0
718    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
719    /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
720    /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
721    /// if cents and right:
722    ///     result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
723    /// if is_negative:
724    ///     result = self.negword + result
725    /// return result.strip()
726    /// ```
727    ///
728    /// Note that `separator` is concatenated with no space of its own; FIL's
729    /// signature defaults it to `" "` (not Base's `","`), which is what makes
730    /// "labindalawa euro tatlumpu at apat sentimo" a plain space-joined
731    /// phrase. The `adjective` flag is accepted and dropped on the floor.
732    fn to_currency(
733        &self,
734        val: &CurrencyValue,
735        currency: &str,
736        cents: bool,
737        separator: Option<&str>,
738        // `adjective=False` is in the signature and never read (bug 10).
739        _adjective: bool,
740    ) -> Result<String> {
741        let separator = separator.unwrap_or(self.default_separator());
742
743        // `is_negative = val < 0` is read *before* the abs below. Python's
744        // `-0.0 < 0` is False, and BigDecimal("-0.0") is likewise not
745        // negative, so the two agree on that edge.
746        let is_negative = val.is_negative();
747
748        let (left, right) = Self::currency_parts(val);
749
750        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — an unknown
751        // code silently becomes PHP rather than raising (bug 8).
752        let forms = self
753            .currency_forms
754            .get(currency)
755            .or_else(|| self.currency_forms.get(FALLBACK_CURRENCY))
756            .expect("PHP is always present in CURRENCY_FORMS");
757        let (cr1, cr2) = (&forms.unit, &forms.subunit);
758
759        // `cr1[1] if left != 1 else cr1[0]` — a direct subscript, not
760        // pluralize(). Index 1 is in range for every entry in the table above,
761        // so this cannot be the IndexError Python would raise on a 1-tuple.
762        let mut result = format!(
763            "{} {}",
764            self.int_to_word(&left),
765            if left.is_one() { &cr1[0] } else { &cr1[1] }
766        );
767
768        // `if cents and right:` — right == 0 is falsy, so a whole float such
769        // as 1.0 prints no cents segment at all ("isa euro"), and neither does
770        // any true int.
771        if cents && !right.is_zero() {
772            result.push_str(separator);
773            result.push_str(&self.int_to_word(&right));
774            result.push(' ');
775            result.push_str(if right.is_one() { &cr2[0] } else { &cr2[1] });
776        }
777
778        if is_negative {
779            // NEGWORD carries its own trailing space; Python adds none.
780            result = format!("{}{}", NEGWORD, result);
781        }
782
783        // `.strip()` — faithful but inert: neither branch above can leave
784        // surrounding whitespace.
785        Ok(result.trim().to_string())
786    }
787}