Skip to main content

num2words2_core/
lang_tl.rs

1//! Port of `lang_TL.py` (Tagalog).
2//!
3//! Shape: **self-contained**. `Num2Word_TL` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so
5//! `Num2Word_Base.__init__` never builds `self.cards` and never sets
6//! `self.MAXVAL`. `to_cardinal` is overridden outright and drives a private
7//! `_int_to_word` recursion. Consequently `cards`/`maxval`/`merge` stay at
8//! their trait defaults here, and there is **no overflow check** — see the
9//! 10^9 fallback below for what Tagalog does instead of raising.
10//!
11//! `is_title` is left `False` by `Num2Word_Base.__init__` and TL's
12//! `to_cardinal` never calls `self.title(...)` anyway, so no title-casing.
13//!
14//! Everything in scope is overridden by TL itself, except the cheque surface:
15//!   * `to_cardinal`    — overridden (below)
16//!   * `to_ordinal`     — overridden ("una"/"ikalawa"/"ika-" + cardinal)
17//!   * `to_ordinal_num` — overridden (`str(number) + "."`)
18//!   * `to_year`        — overridden (`to_cardinal(val)`; the base class's
19//!     BC/AD machinery is bypassed entirely, so `longval` is inert)
20//!   * `to_currency`    — overridden outright (below). It never calls up into
21//!     `Num2Word_Base.to_currency`, so `parse_currency_parts`, `pluralize`,
22//!     `_money_verbose`, `_cents_verbose`, `_cents_terse`, `CURRENCY_PRECISION`
23//!     and `CURRENCY_ADJECTIVES` are all unreachable from it.
24//!   * `to_cheque`      — **not** overridden; `Num2Word_Base.to_cheque` runs,
25//!     so the trait default (`currency::default_to_cheque`) is correct here and
26//!     only `currency_forms`/`lang_name` need supplying. Verified against the
27//!     live interpreter: `type(c).to_cheque.__qualname__` is
28//!     `Num2Word_Base.to_cheque`.
29//!
30//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}` — TL declares
31//! neither and `Num2Word_Base` leaves them empty — so `currency_precision`
32//! stays at its default 100 for every code and `currency_adjective` at `None`.
33//! Confirmed on the live interpreter, not read off the source.
34//!
35//! `Num2Word_TL` declares its own `CURRENCY_FORMS` dict literal and inherits
36//! from `Num2Word_Base`, **not** `Num2Word_EUR` (MRO: `Num2Word_TL`,
37//! `Num2Word_Base`, `object`). The `Num2Word_EN.__init__` shared-class-dict
38//! rewrite documented in PORTING_CURRENCY.md therefore does not reach this
39//! table: TL's EUR really is `("euro", "euros")` in its own right, and TL sees
40//! none of the ~24 codes EN adds to the `Num2Word_EUR` dict.
41//!
42//! # Faithfully reproduced Python bugs / oddities
43//!
44//! This is a port, not a rewrite. Every item below looks wrong but is exactly
45//! what Python emits, and each is confirmed by a row in the frozen corpus:
46//!
47//! 1. **No connectives anywhere.** Real Tagalog joins with the linker "at"
48//!    ("isang daan at isa") and uses ligatures ("isang daan", not "isa daan").
49//!    This module just space-joins bare stems, so `to_cardinal(101)` is
50//!    `"isa daan isa"` and `to_cardinal(11)` is `"sampu isa"` — the latter is
51//!    not a Tagalog word for 11 ("labing-isa") at all, it is literally
52//!    "ten one". Preserved verbatim.
53//! 2. **Values >= 10^9 are not converted at all.** `_int_to_word` has no
54//!    billion branch and falls through to `return str(number)`, so
55//!    `to_cardinal(10**9) == "1000000000"` — digits, not words — and
56//!    `to_cardinal(10**21) == "1000000000000000000000"`. This is Tagalog's
57//!    de facto (and entirely silent) MAXVAL: it neither raises `OverflowError`
58//!    nor degrades gracefully. Modelled by the final arm of [`LangTl::int_to_word`].
59//!    Note the sign is stripped *before* this fallback, so
60//!    `to_cardinal(-10**9)` is `"minus 1000000000"`.
61//! 3. **`to_ordinal` accepts negatives and zero and emits nonsense.** It has
62//!    no sign/zero guard (unlike most modules, which raise `TypeError` via
63//!    `errmsg_negord`), so it just prefixes the cardinal:
64//!    `to_ordinal(0) == "ika-zero"` and `to_ordinal(-1) == "ika-minus isa"`.
65//!    The `"ika-"` prefix is also glued onto multi-word cardinals, giving
66//!    `to_ordinal(10**6) == "ika-isa milyon"` (prefix binds the first word only).
67//! 4. **`ones[0]` is `""`,** and `_int_to_word` guards with
68//!    `return self.ones[0] if self.ones[0] else "zero"` — a conditional whose
69//!    true arm is unreachable, since `""` is falsy. Zero is therefore always
70//!    the English loanword `"zero"` (Tagalog "sero"/"wala" never appears).
71//!    Likewise `pointword` is the English `"point"`.
72//! 5. **The `number < 0` arm of `_int_to_word` is dead code** on *every* path,
73//!    not just the cardinal one: `to_cardinal` strips the `"-"` from the
74//!    *string* before calling `int()`, and `to_currency` does `val = abs(val)`
75//!    before it splits, so `_int_to_word` never sees a negative. It is
76//!    reproduced anyway; if it ever ran it would double the negword
77//!    (`"minus " + "minus " + ...`).
78//! 6. **`to_currency` never raises on an unknown code.** Where every other
79//!    module does `self.CURRENCY_FORMS[currency]` and converts the `KeyError`
80//!    into `NotImplementedError`, TL does
81//!    `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — an unknown code
82//!    silently falls back to the **first-inserted** entry. So `currency="JPY"`
83//!    renders Philippine pisos, and the corpus agrees:
84//!    `currency:JPY 12.34 -> "sampu dalawa piso tatlumpu apat sentimo"`.
85//!    See [`FALLBACK_CURRENCY`].
86//! 7. **`to_currency` ignores `CURRENCY_PRECISION` entirely.** The divisor is
87//!    the hard-coded 2 in `parts[1][:2].ljust(2, "0")`. TL's precision map is
88//!    empty anyway, so this is unobservable *through TL* — but it means a
89//!    3-decimal code (KWD/BHD) and a 0-decimal one (JPY) are both treated as
90//!    2-decimal, on top of bug 6 renaming them "piso". Corpus:
91//!    `currency:KWD 0.01 -> "zero piso isa sentimo"` (not `.../1000`), and
92//!    `currency:JPY 0.5 -> "zero piso limampu sentimo"` — a subunit for a
93//!    currency that has none.
94//! 8. **`to_currency`'s `adjective` parameter is declared and never read.**
95//!    `adjective=True` changes nothing. (`CURRENCY_ADJECTIVES` is `{}` too, so
96//!    even `Num2Word_Base` would have been a no-op here.)
97//! 9. **Cents truncate, they do not round.** `parts[1][:2]` slices the decimal
98//!    string, so `1.005` -> `"00"` -> 0 cents (dropped entirely), and `0.999`
99//!    -> `"99"` -> 99 cents, never 100. `Num2Word_Base.to_currency` would have
100//!    quantized ROUND_HALF_UP instead.
101//! 10. **`to_cheque` covers strictly fewer codes than `to_currency`.** Cheque
102//!    is `Num2Word_Base`'s, which indexes `CURRENCY_FORMS[currency]` and does
103//!    raise — so the 7 codes that bug 6 silently renders as pisos through
104//!    `to_currency` raise `NotImplementedError` through `to_cheque`. Both
105//!    halves are in the corpus and both are reproduced.
106//!
107//! # Error variants
108//!
109//! `to_cardinal`/`to_ordinal`/`to_ordinal_num`/`to_year` cannot raise: no
110//! overflow check, no negative guard, no table lookup that can miss
111//! (`ones`/`tens` are indexed only by a digit derived from `divmod`). All 324
112//! integer-mode corpus rows are `ok: true`.
113//!
114//! `to_currency` cannot raise either — bug 6 removes the only `KeyError` site,
115//! and all 108 currency corpus rows are `ok: true`.
116//!
117//! `to_cheque` raises exactly one thing: [`N2WError::NotImplemented`] with
118//! Python's message, `Currency code "%s" not implemented for "%s"`. That comes
119//! from `currency::default_to_cheque` via [`LangTl::currency_forms`] returning
120//! `None`, so nothing here needs to construct it. 7 of the 9 cheque corpus rows
121//! take that path.
122
123use crate::base::{Lang, N2WError, Result};
124use crate::currency::{CurrencyForms, CurrencyValue};
125use crate::floatpath::FloatValue;
126use crate::strnum::{python_decimal_parse, ParsedNumber};
127use bigdecimal::BigDecimal;
128use num_bigint::BigInt;
129use num_integer::Integer;
130use num_traits::{One, Signed, ToPrimitive, Zero};
131use std::collections::HashMap;
132
133/// `self.negword`, verbatim from `setup()` — note the **trailing space**, which
134/// is load-bearing: `to_cardinal` concatenates it directly (it does not go
135/// through `Num2Word_Base.parse_minus`, which would `.strip()` and re-add one).
136const NEGWORD: &str = "minus ";
137
138/// `self.pointword`. The English loanword, reached on the float/Decimal path
139/// (see [`LangTl::cardinal_from_str`] / [`LangTl::to_cardinal_float`]).
140const POINTWORD: &str = "point";
141
142/// The `"zero"` literal from `_int_to_word`'s falsy-`ones[0]` guard.
143const ZERO_WORD: &str = "zero";
144
145/// `self.ones`. Index 0 is `""` and is never returned (see bug 4).
146const ONES: [&str; 10] = [
147    "", "isa", "dalawa", "tatlo", "apat", "lima", "anim", "pito", "walo", "siyam",
148];
149
150/// `self.tens`. Index 0 is `""` and is unreachable (the `< 100` branch is only
151/// entered when `number >= 10`, so `tens_val >= 1`).
152const TENS: [&str; 10] = [
153    "",
154    "sampu",
155    "dalawampu",
156    "tatlumpu",
157    "apatnapu",
158    "limampu",
159    "animnapu",
160    "pitumpu",
161    "walumpu",
162    "siyamnapu",
163];
164
165const HUNDRED: &str = "daan";
166const THOUSAND: &str = "libo";
167const MILLION: &str = "milyon";
168
169/// The Python class name, for `Num2Word_Base.to_cheque`'s NotImplementedError
170/// message (`self.__class__.__name__`).
171const LANG_NAME: &str = "Num2Word_TL";
172
173/// `Num2Word_TL.to_currency`'s own default `separator=" "` — a bare space.
174const SEPARATOR_DEFAULT: &str = " ";
175
176/// The separator the pyo3 binding hands us when the Python caller omitted one.
177///
178/// `Num2Word_TL.to_currency` declares `separator=" "`, but the `Lang` trait
179/// carries no per-language parameter defaults, and both `__init__.py`'s
180/// currency fast path and `bench/diff_test.py` substitute
181/// `kwargs.get("separator", ",")` — **`Num2Word_Base`'s** default, not TL's —
182/// before the value crosses the boundary. By then "caller omitted separator"
183/// and "caller explicitly passed a comma" are the same `&str`, and the
184/// information needed to tell them apart no longer exists on this side.
185///
186/// So `,` is read back as the unset sentinel and TL's own default restored.
187/// This is the only reading the oracle supports: every float row of the `tl`
188/// currency corpus was generated by `num2words(v, lang="tl", to="currency",
189/// currency=c)` with no `separator=`, and all 54 of them expect a bare space
190/// ("sampu dalawa euros tatlumpu apat cents", not "...euros,tatlumpu apat
191/// cents").
192///
193/// The cost is narrow and known: a caller who *explicitly* passes
194/// `separator=","` gets " " where Python would give ",". Expressing that case
195/// needs `Option<&str>` in the trait signature, which lives in `base.rs` —
196/// outside this port's remit. Flagged in the port report. `lang_bo.rs`,
197/// `lang_ca.rs`, `lang_es.rs`, `lang_eu.rs` and `lang_fo.rs` resolve the
198/// identical conflict the same way.
199const SEPARATOR_UNSET: &str = ",";
200
201/// `to_currency`'s fallback code — see bug 6.
202///
203/// Python spells it `list(self.CURRENCY_FORMS.values())[0]`, i.e. "whichever
204/// entry was inserted first". `CURRENCY_FORMS` is a dict literal ordered
205/// `PHP, USD, EUR` and dicts preserve insertion order, so that is PHP. Pinning
206/// the *key* rather than reproducing a positional lookup keeps the intent
207/// legible; a `HashMap` has no insertion order to index into anyway.
208///
209/// Verified against the live interpreter rather than read off the source:
210/// `list(CONVERTER_CLASSES["tl"].CURRENCY_FORMS.keys())` == `['PHP', 'USD',
211/// 'EUR']`.
212const FALLBACK_CURRENCY: &str = "PHP";
213
214/// The hard-coded 2 in `parts[1][:2].ljust(2, "0")` — see bugs 7 and 9.
215///
216/// Deliberately *not* `currency_precision(code)`: TL's `to_currency` never
217/// consults `CURRENCY_PRECISION`, so this stays 100 even for KWD/BHD/JPY.
218const CENT_DIVISOR: i64 = 100;
219
220pub struct LangTl {
221    /// `Num2Word_TL.CURRENCY_FORMS`.
222    ///
223    /// Built once in [`LangTl::new`] and stored, never per call: the binding
224    /// holds the converter in a `OnceLock`, so this table is constructed
225    /// exactly once per process.
226    ///
227    /// Each entry is a 2-tuple of 2-tuples, `((unit_sg, unit_pl), (sub_sg,
228    /// sub_pl))`. The arity is load-bearing: `to_currency` indexes `[1]` for
229    /// the plural and `Num2Word_Base.to_cheque` takes `cr1[-1]`, so both forms
230    /// must be present — including for PHP, where the two are *identical*
231    /// ("piso"/"piso"). Collapsing PHP to a single form would still print
232    /// correctly today but would silently change `cr1[-1]`'s meaning.
233    currency_forms: HashMap<&'static str, CurrencyForms>,
234}
235
236impl Default for LangTl {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242impl LangTl {
243    pub fn new() -> Self {
244        // Insertion order is irrelevant to a HashMap; FALLBACK_CURRENCY
245        // captures the one place Python's ordering was observable.
246        let currency_forms: HashMap<&'static str, CurrencyForms> = [
247            ("PHP", &["piso", "piso"][..], &["sentimo", "sentimo"][..]),
248            ("USD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
249            ("EUR", &["euro", "euros"][..], &["cent", "cents"][..]),
250        ]
251        .into_iter()
252        .map(|(k, u, s)| (k, CurrencyForms::new(u, s)))
253        .collect();
254        LangTl { currency_forms }
255    }
256
257    /// The `(left, right)` split at the head of `Num2Word_TL.to_currency`, for
258    /// a value already made non-negative.
259    ///
260    /// Python works on the *string*:
261    ///
262    /// ```python
263    /// parts = str(val).split(".")
264    /// left = int(parts[0]) if parts[0] else 0
265    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
266    /// ```
267    ///
268    /// Taking the first two fractional digits and right-padding to two is
269    /// exactly `trunc(frac * 100)` for any plain decimal string — `"5"` →
270    /// `"50"` → 50 is `trunc(0.5 * 100)`, `"345"` → `"34"` is
271    /// `trunc(0.345 * 100)` — so this computes the split arithmetically rather
272    /// than round-tripping the value back through `BigDecimal`'s `Display`.
273    /// Identical results, one less dependency on a formatting impl we do not
274    /// control. (`lang_eu.rs` and `lang_fo.rs` port the same idiom the same
275    /// way.)
276    ///
277    /// A true `int` has no "." in `str(val)`, so `len(parts) > 1` is false and
278    /// `right` is 0 — which is also what `frac == 0` gives here. TL therefore
279    /// needs no `isinstance(val, int)` branch: it never had one, and the
280    /// int/float distinction is invisible in its output. `1.0` reaches
281    /// `parts[1] == "0"` (truthy!), so Python computes
282    /// `int("0".ljust(2, "0")) == 0` and then `if cents and right:` drops the
283    /// segment on the *value* being 0 — not on the type. `Int(1)` and
284    /// `Decimal(1.0)` therefore both give "isa euro", which the corpus
285    /// confirms for both `arg: "1"` and `arg: "1.0"`.
286    ///
287    /// The one input this cannot reproduce is a float whose `str()` is
288    /// scientific notation (`1e+16`, `1e-05`): Python feeds that to `int()` and
289    /// raises `ValueError`. `str()` already happened on the Python side, and
290    /// `BigDecimal::from_str` parses `"1e-05"` and `"0.00001"` to the identical
291    /// value+scale, so the two are no longer distinguishable here. No corpus
292    /// row exercises it; flagged in the port report.
293    fn split_currency(val: &CurrencyValue) -> (BigInt, BigInt) {
294        match val {
295            CurrencyValue::Int(i) => (i.abs(), BigInt::zero()),
296            CurrencyValue::Decimal { value: d, .. } => {
297                let abs = d.abs();
298                // int(parts[0]) — with_scale(0) truncates via BigInt division,
299                // and abs is non-negative, so this is floor as well.
300                let left = abs.with_scale(0).as_bigint_and_exponent().0;
301                let frac = &abs - BigDecimal::from(left.clone());
302                let right = (frac * BigDecimal::from(CENT_DIVISOR))
303                    .with_scale(0)
304                    .as_bigint_and_exponent()
305                    .0;
306                (left, right)
307            }
308        }
309    }
310
311    /// Port of `Num2Word_TL._int_to_word`.
312    ///
313    /// Infallible — mirrors Python, which cannot raise here (see module docs).
314    /// Recursion depth is bounded at 4 (milyon → libo → daan → ones).
315    fn int_to_word(&self, number: &BigInt) -> String {
316        if number.is_zero() {
317            // Python: `return self.ones[0] if self.ones[0] else "zero"`.
318            // ones[0] == "" is falsy, so the else arm always wins.
319            return ZERO_WORD.to_string();
320        }
321
322        if number.is_negative() {
323            // Dead code on every in-scope path (see bug 5), reproduced anyway.
324            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
325        }
326
327        let ten = BigInt::from(10);
328        let hundred = BigInt::from(100);
329        let thousand = BigInt::from(1000);
330        let million = BigInt::from(1_000_000);
331        let billion = BigInt::from(1_000_000_000);
332
333        // From here on `number >= 1`, so div_mod_floor == Python's divmod
334        // (both operands positive: floor and truncation agree).
335
336        if number < &ten {
337            // 1..=9 — to_usize always succeeds.
338            return ONES[number.to_usize().unwrap()].to_string();
339        }
340
341        if number < &hundred {
342            let (tens_val, ones_val) = number.div_mod_floor(&ten);
343            let t = TENS[tens_val.to_usize().unwrap()];
344            if ones_val.is_zero() {
345                return t.to_string();
346            }
347            return format!("{} {}", t, ONES[ones_val.to_usize().unwrap()]);
348        }
349
350        if number < &thousand {
351            let (hundreds_val, remainder) = number.div_mod_floor(&hundred);
352            // Python indexes `self.ones[hundreds_val]` directly — no recursion —
353            // so 100..=999 always yields a bare "<ones> daan" head.
354            let mut result = format!("{} {}", ONES[hundreds_val.to_usize().unwrap()], HUNDRED);
355            if !remainder.is_zero() {
356                result.push(' ');
357                result.push_str(&self.int_to_word(&remainder));
358            }
359            return result;
360        }
361
362        if number < &million {
363            let (thousands_val, remainder) = number.div_mod_floor(&thousand);
364            let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
365            if !remainder.is_zero() {
366                result.push(' ');
367                result.push_str(&self.int_to_word(&remainder));
368            }
369            return result;
370        }
371
372        if number < &billion {
373            let (millions_val, remainder) = number.div_mod_floor(&million);
374            let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
375            if !remainder.is_zero() {
376                result.push(' ');
377                result.push_str(&self.int_to_word(&remainder));
378            }
379            return result;
380        }
381
382        // Python: `return str(number)` — "Fallback for very large numbers".
383        // Emits digits, not words. See bug 2.
384        number.to_string()
385    }
386
387    /// The body of `Num2Word_TL.to_cardinal` when its argument is a
388    /// float/Decimal, driven by `str(number)`:
389    ///
390    /// ```python
391    /// n = str(number).strip()
392    /// if n.startswith("-"):
393    ///     n = n[1:]
394    ///     ret = self.negword
395    /// else:
396    ///     ret = ""
397    /// if "." in n:
398    ///     left, right = n.split(".", 1)
399    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
400    ///     for digit in right:
401    ///         ret += self._int_to_word(int(digit)) + " "
402    ///     return ret.strip()
403    /// else:
404    ///     return (ret + self._int_to_word(int(n))).strip()
405    /// ```
406    ///
407    /// Three details, all shared with the reviewed sibling ports (`lang_pa`,
408    /// `lang_as`):
409    ///
410    /// * The sign is stripped **textually** (`n.startswith("-")`), so `-0.0`
411    ///   — whose `str` is `"-0.0"` — keeps its negword even though the value is
412    ///   not `< 0`: Python answers "minus zero point zero". (Reachable only for
413    ///   `FloatValue::Float`; a `BigDecimal` cannot carry negative zero — see
414    ///   the report.)
415    /// * `split(".", 1)` caps at one split, so a second dot would stay inside
416    ///   `right` and detonate in the digit loop (`int('.')` → `ValueError`)
417    ///   rather than being ignored. `str()` never produces one.
418    /// * `int(left)` runs *before* the digit loop, so for a `str` like
419    ///   `"1.5e+16"` the failing literal reported is `'e'`, not `"1.5e+16"` —
420    ///   order is load-bearing for the `ValueError` message.
421    ///
422    /// Note `_int_to_word` is called, **not** `to_cardinal`: identical for every
423    /// non-negative value (the only kind reachable here, the sign having been
424    /// peeled), but it is what Python writes.
425    ///
426    /// Python builds the fractional part by appending `word + " "` per digit and
427    /// then `.strip()`s; a space-separated join plus a trailing `.trim()` is
428    /// byte-identical. The `.trim()` is otherwise a no-op (`int_to_word` never
429    /// returns "" — 0 short-circuits to "zero"), kept for fidelity.
430    fn cardinal_from_str(&self, number: &str) -> Result<String> {
431        let n = number.trim();
432        let (n, mut ret) = match n.strip_prefix('-') {
433            Some(rest) => (rest, NEGWORD.to_string()),
434            None => (n, String::new()),
435        };
436
437        let Some(dot) = n.find('.') else {
438            // else: (ret + self._int_to_word(int(n))).strip()
439            ret.push_str(&self.int_to_word(&py_int(n)?));
440            return Ok(ret.trim().to_string());
441        };
442
443        // n.split(".", 1) — maxsplit=1, so `right` keeps any further dots.
444        let (left, right) = (&n[..dot], &n[dot + 1..]);
445        ret.push_str(&self.int_to_word(&py_int(left)?));
446        ret.push(' ');
447        ret.push_str(POINTWORD);
448        ret.push(' ');
449
450        // for digit in right: ret += _int_to_word(int(digit)) + " " — iterate
451        // *characters*, one per fractional digit.
452        let mut first = true;
453        for d in right.chars() {
454            if !first {
455                ret.push(' ');
456            }
457            first = false;
458            let mut buf = [0u8; 4];
459            ret.push_str(&self.int_to_word(&py_int(d.encode_utf8(&mut buf))?));
460        }
461        Ok(ret.trim().to_string())
462    }
463}
464
465impl Lang for LangTl {
466
467    fn cardinal_float_entry(
468        &self,
469        value: &crate::floatpath::FloatValue,
470        precision_override: Option<u32>,
471    ) -> crate::base::Result<String> {
472        // Python's to_cardinal routes every float/Decimal through this
473        // language's own decimal grammar — 5.0 keeps its ".0" tail
474        // ("comma nulla"), unlike Base's whole-value integer route.
475        self.to_cardinal_float(value, precision_override)
476    }
477
478    /// `to_ordinal(float/Decimal)`. TL's `to_ordinal` compares the raw value
479    /// first — `number == 1` / `number == 2` are *numeric* equality, so the
480    /// whole floats 1.0 / 2.0 (and `Decimal("1.0")`) hit the special forms
481    /// "una" / "ikalawa". Everything else is `"ika-" + self.to_cardinal(number)`,
482    /// i.e. the float string grammar with the prefix: "ika-lima point zero".
483    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
484        let (is_one, is_two) = match value {
485            FloatValue::Float { value, .. } => (*value == 1.0, *value == 2.0),
486            FloatValue::Decimal { value, .. } => (
487                *value == BigDecimal::from(1),
488                *value == BigDecimal::from(2),
489            ),
490        };
491        if is_one {
492            return Ok("una".to_string());
493        }
494        if is_two {
495            return Ok("ikalawa".to_string());
496        }
497        Ok(format!("ika-{}", self.cardinal_float_entry(value, None)?))
498    }
499
500    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — the repr the
501    /// binding computed, dot appended, sign and exponent form included
502    /// ("-0.0.", "1e+16.").
503    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
504        Ok(format!("{}.", repr_str))
505    }
506
507    /// `converter.str_to_number` — Base's `Decimal(value)`, with the Inf
508    /// interception: Python parses "Infinity" fine and the ValueError only
509    /// fires later, inside TL's `int("Infinity")` (`to_cardinal` reads
510    /// `str(number)`, strips the sign, finds no "." and calls `int()`).
511    /// The binding otherwise hard-codes `ParsedNumber::Inf` to the base
512    /// integer path's OverflowError before any TL code runs, so the
513    /// ValueError must be raised here. The sign is sliced off before
514    /// `int()` sees it, so both signs quote the same literal.
515    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
516        match python_decimal_parse(s)? {
517            ParsedNumber::Inf { .. } => Err(N2WError::Value(
518                "invalid literal for int() with base 10: 'Infinity'".into(),
519            )),
520            other => Ok(other),
521        }
522    }
523
524    /// This language's own `to_currency(currency=...)` default,
525    /// read from the live Python signature. Only 44 of 156 use EUR.
526    fn default_currency(&self) -> &str {
527        "PHP"
528    }
529
530    /// This language's own `to_currency(separator=...)` default,
531    /// read from the live Python signature. Base's is ",", but only
532    /// 36 of 149 languages actually use it — most default to " " or a
533    /// conjunction, so inheriting Base's comma silently corrupts them.
534    fn default_separator(&self) -> &str {
535        " "
536    }
537
538    fn negword(&self) -> &str {
539        NEGWORD
540    }
541
542    fn pointword(&self) -> &str {
543        "point"
544    }
545
546    /// The float/Decimal path. `Num2Word_TL` never defines `to_cardinal_float`;
547    /// its `to_cardinal` handles both int and float via `str(number)` and
548    /// branches on `"." in n`. `Num2Word_Base.to_cardinal_float`/`float2tuple`
549    /// are therefore **never reached** — which matters here, because the base
550    /// path reconstructs the fractional digits from `float2tuple`'s binary
551    /// `abs(value-pre) * 10**precision` and its `< 0.01` floor/round heuristic,
552    /// whereas TL reads them straight out of `repr(float)`. The two disagree on
553    /// the **last** fractional digit of high-precision floats (e.g.
554    /// `-9.388200339328929` ends in "siyam" via `str`, but the base path floors
555    /// the scaled product to "…walo"), so inheriting the default is wrong.
556    ///
557    /// `precision_override` (the `precision=` kwarg) is accepted by the
558    /// dispatcher but TL's `to_cardinal` takes no such parameter and reads every
559    /// digit of `str(value)` regardless, so it is dropped — confirmed on the
560    /// live interpreter (`num2words(2.675, lang='tl', precision=1)` is
561    /// unchanged).
562    fn to_cardinal_float(
563        &self,
564        value: &FloatValue,
565        precision_override: Option<u32>,
566    ) -> Result<String> {
567        let _ = precision_override;
568        let n = match value {
569            // Python's str(float) == repr(float). The raw f64 crosses the
570            // boundary so repr can be reproduced from the bits.
571            FloatValue::Float { value, .. } => py_float_repr(*value),
572            // Python's str(Decimal) — exact, never routed through f64.
573            FloatValue::Decimal { value, .. } => py_decimal_str(value),
574        };
575        self.cardinal_from_str(&n)
576    }
577
578    /// Port of `Num2Word_TL.to_cardinal`, integer path only.
579    ///
580    /// Python stringifies the input, peels a leading `"-"` into `ret`, then
581    /// looks for `"."`. `str(int)` never contains one, so integers always take
582    /// the `else` branch: `(ret + self._int_to_word(int(n))).strip()`. The
583    /// float branch (`pointword` + per-digit decimals) is handled by
584    /// [`LangTl::to_cardinal_float`] / [`LangTl::cardinal_from_str`], since the
585    /// Rust dispatcher routes non-integers there rather than through this
586    /// `&BigInt` entry point.
587    ///
588    /// Peeling the sign off the *string* means `_int_to_word` receives the
589    /// absolute value; `BigInt::abs` reproduces `n[1:]` exactly (there is no
590    /// negative zero to disagree about).
591    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
592        let (ret, n) = if value.is_negative() {
593            (NEGWORD, value.abs())
594        } else {
595            ("", value.clone())
596        };
597
598        // `.strip()` — only ever trims NEGWORD's trailing space when
599        // _int_to_word returns "" (which it cannot), so it is a no-op in
600        // practice. Kept for fidelity.
601        Ok(format!("{}{}", ret, self.int_to_word(&n))
602            .trim()
603            .to_string())
604    }
605
606    /// Port of `Num2Word_TL.to_ordinal`.
607    ///
608    /// No negative/zero guard and no float guard — see bug 3.
609    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
610        if value.is_one() {
611            return Ok("una".to_string()); // first
612        }
613        if value == &BigInt::from(2) {
614            return Ok("ikalawa".to_string()); // second
615        }
616        Ok(format!("ika-{}", self.to_cardinal(value)?))
617    }
618
619    /// Port of `Num2Word_TL.to_ordinal_num`: `str(number) + "."`.
620    ///
621    /// Not the trait default (which returns the bare value) — TL appends a
622    /// period, and does so for negatives too: `to_ordinal_num(-1) == "-1."`.
623    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
624        Ok(format!("{}.", value))
625    }
626
627    /// Port of `Num2Word_TL.to_year`: `return self.to_cardinal(val)`.
628    ///
629    /// TL discards `longval` and bypasses `Num2Word_Base.to_year` entirely, so
630    /// there is no BC/AD suffix and no two-chunk ("nineteen eighty-four")
631    /// reading: `to_year(-44) == "minus apatnapu apat"`, not "44 BC".
632    fn to_year(&self, value: &BigInt) -> Result<String> {
633        self.to_cardinal(value)
634    }
635
636    // ---- currency ----------------------------------------------------
637
638    /// `self.__class__.__name__`, for `to_cheque`'s NotImplementedError.
639    fn lang_name(&self) -> &str {
640        LANG_NAME
641    }
642
643    /// `Num2Word_TL.CURRENCY_FORMS[code]`.
644    ///
645    /// Only `Num2Word_Base.to_cheque` reads this through the trait — TL's own
646    /// `to_currency` bypasses it for the `.get(...)`-with-fallback of bug 6, so
647    /// returning `None` here is what makes an unknown code raise on the cheque
648    /// path while still rendering pisos on the currency path.
649    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
650        self.currency_forms.get(code)
651    }
652
653    // `currency_adjective` and `currency_precision` stay at their trait
654    // defaults (`None` / 100): TL declares neither map and `Num2Word_Base`
655    // leaves both `{}`, so `CURRENCY_PRECISION.get(code, 100)` is 100 for every
656    // code — which is what `to_cheque` uses to print "56/100". `pluralize` also
657    // stays at the raising default: it is abstract in `Num2Word_Base` and
658    // nothing TL can reach ever calls it (TL's `to_currency` selects the plural
659    // inline with `left != 1`, and `to_cheque` takes `cr1[-1]` unconditionally).
660    // `cardinal_from_decimal` likewise stays at its raising default — TL has no
661    // fractional-cents path to reach it.
662
663    /// Port of `Num2Word_TL.to_currency`:
664    ///
665    /// ```python
666    /// def to_currency(self, val, currency="PHP", cents=True, separator=" ",
667    ///                 adjective=False):
668    ///     is_negative = False
669    ///     if val < 0:
670    ///         is_negative = True
671    ///         val = abs(val)
672    ///
673    ///     parts = str(val).split(".")
674    ///     left = int(parts[0]) if parts[0] else 0
675    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
676    ///
677    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
678    ///         currency, list(self.CURRENCY_FORMS.values())[0]
679    ///     )
680    ///
681    ///     left_str = self._int_to_word(left)
682    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
683    ///
684    ///     if cents and right:
685    ///         cents_str = self._int_to_word(right)
686    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
687    ///
688    ///     if is_negative:
689    ///         result = self.negword + result
690    ///
691    ///     return result.strip()
692    /// ```
693    ///
694    /// A complete override: `Num2Word_Base.to_currency` is never called, so
695    /// none of `parse_currency_parts`, `pluralize`, `_money_verbose`,
696    /// `_cents_verbose`, `_cents_terse`, `CURRENCY_PRECISION` or
697    /// `CURRENCY_ADJECTIVES` is reachable from here. Infallible — there is no
698    /// `NotImplementedError` path (bug 6) and no table index that can escape
699    /// its range.
700    ///
701    /// Note the plural is selected by `left != 1` / `right != 1` directly
702    /// rather than by `pluralize`, which `Num2Word_TL` leaves abstract. Note
703    /// also that `_int_to_word` is called, **not** `to_cardinal`: identical for
704    /// every non-negative value (the only kind that gets here), but it is what
705    /// Python writes.
706    ///
707    /// The `default_to_currency` "pure ints never show cents" branch has no
708    /// analogue here and must not be reintroduced: TL reaches the same outcome
709    /// through the `if cents and right:` truthiness test instead. See
710    /// [`LangTl::split_currency`].
711    fn to_currency(
712        &self,
713        val: &CurrencyValue,
714        currency: &str,
715        cents: bool,
716        separator: Option<&str>,
717        _adjective: bool, // declared by Python, never read — bug 8.
718    ) -> Result<String> {
719        // Trait now hands us None when the caller omitted separator=;
720        // resolve it to this language's own default before the ported body.
721        let separator = separator.unwrap_or(self.default_separator());
722        let separator = if separator == SEPARATOR_UNSET {
723            SEPARATOR_DEFAULT
724        } else {
725            separator
726        };
727
728        // Python: `if val < 0: is_negative = True; val = abs(val)`. The abs is
729        // folded into split_currency, which every later step reads through.
730        let is_negative = val.is_negative();
731        let (left, right) = Self::split_currency(val);
732
733        // `.get(currency, list(CURRENCY_FORMS.values())[0])` — bug 6. Python
734        // evaluates the default eagerly on every call, but it cannot fail:
735        // PHP is always present.
736        let forms = match self.currency_forms.get(currency) {
737            Some(f) => f,
738            None => &self.currency_forms[FALLBACK_CURRENCY],
739        };
740
741        let one = BigInt::one();
742        let left_str = self.int_to_word(&left);
743        let mut result = format!(
744            "{} {}",
745            left_str,
746            if left != one {
747                &forms.unit[1]
748            } else {
749                &forms.unit[0]
750            }
751        );
752
753        // `if cents and right:` — a truthiness test on the cent *count*, not on
754        // the type of `val`. This is what makes `1.0` print "isa euro" with no
755        // cents segment even though it is a float.
756        if cents && !right.is_zero() {
757            let cents_str = self.int_to_word(&right);
758            result.push_str(separator);
759            result.push_str(&cents_str);
760            result.push(' ');
761            result.push_str(if right != one {
762                &forms.subunit[1]
763            } else {
764                &forms.subunit[0]
765            });
766        }
767
768        if is_negative {
769            // negword carries a trailing space ("minus "), so this is the seam
770            // the final strip() does *not* touch.
771            result = format!("{}{}", NEGWORD, result);
772        }
773
774        Ok(result.trim().to_string())
775    }
776}
777
778// ---- str(float) / str(Decimal) reproduction ---------------------------------
779//
780// `Num2Word_TL.to_cardinal` promotes `str(number)` from a formatting detail to
781// the entire specification of the float path, so these reproduce CPython's
782// `repr`/`str` exactly. They are value-agnostic and copied verbatim from the
783// reviewed sibling ports (`lang_pa`, `lang_as`), where they were fuzzed over
784// millions of values; nothing here is TL-specific.
785
786/// Shortest round-trip decimal digits of a **non-negative, finite** f64, plus
787/// the decimal point position `decpt` (value == 0.d1d2… × 10^decpt, i.e. the
788/// first digit sits just left of position `decpt`). This is CPython's
789/// `_Py_dg_dtoa(mode 0)`, the David Gay shortest-representation used by `repr`.
790///
791/// Rust's `{:e}` is also shortest-round-trip and agrees with Gay on the digit
792/// *count* and, almost always, the digits. It disagrees on **exact ties**: when
793/// the value sits precisely halfway between two shortest candidates, Gay's dtoa
794/// picks the one with an **even** last digit while Rust rounds half **up**.
795/// `repr(-78198386800398.125)` is `'-78198386800398.12'`; Rust's `{:e}` says
796/// `…13`. This mirrors the reviewed sibling implementation (`lang_as`), fuzzed
797/// over millions of values, where that tie is the only divergence.
798///
799/// Detecting the tie needs no bignum. Write `a = m·2^e` with `m` odd and let
800/// `q = digits.len() - decpt`. The tie condition reduces to `e + q + 1 == 0`,
801/// plus (when `q < 0`) `5^-q | m` with `-q <= 22`. In a tie `2k+1 == m·5^q`
802/// (or `m / 5^-q`), and since `5 ≡ 1 (mod 4)` that odd integer is `≡ m (mod 4)`,
803/// so `k` is even exactly when `m % 4 == 1`. The fix-up steps Rust's odd last
804/// digit toward the even neighbour, `k`'s parity choosing the direction.
805fn shortest_digits(a: f64) -> (String, i32) {
806    let sci = format!("{:e}", a);
807    let (mant, exp) = sci
808        .split_once('e')
809        .expect("{:e} on a finite f64 always emits an exponent");
810    let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
811    let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
812
813    // Decompose a == m * 2**e exactly, then reduce m to odd.
814    let bits = a.to_bits();
815    let biased = ((bits >> 52) & 0x7ff) as i32;
816    let frac = bits & ((1u64 << 52) - 1);
817    let (mut m, mut e) = if biased == 0 {
818        (frac, -1074i32) // subnormal: no implicit leading bit
819    } else {
820        (frac | (1u64 << 52), biased - 1075)
821    };
822    if m == 0 {
823        // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
824        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
825    }
826    let z = m.trailing_zeros() as i32;
827    m >>= z;
828    e += z;
829
830    let q = digits.len() as i32 - decpt;
831    let mut tie = e + q + 1 == 0;
832    if tie && q < 0 {
833        let r = -q as u32;
834        tie = r <= 22 && m % 5u64.pow(r) == 0;
835    }
836    if !tie {
837        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
838    }
839
840    let last = digits[digits.len() - 1] - b'0';
841    if last % 2 == 1 {
842        if m % 4 == 1 {
843            // k even: Python wants k, Rust gave k+1. Odd last digit, so no borrow.
844            *digits.last_mut().expect("non-empty") -= 1;
845        } else {
846            // k odd: Python wants k+1, Rust gave k. Carry like dtoa's roundoff.
847            let mut i = digits.len();
848            loop {
849                if i == 0 {
850                    digits.insert(0, b'1');
851                    decpt += 1;
852                    break;
853                }
854                i -= 1;
855                if digits[i] == b'9' {
856                    digits[i] = b'0';
857                } else {
858                    digits[i] += 1;
859                    break;
860                }
861            }
862        }
863        // dtoa never emits trailing zeros; stripping them leaves decpt alone.
864        while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
865            digits.pop();
866        }
867    }
868    (String::from_utf8(digits).expect("ASCII digits"), decpt)
869}
870
871/// Python's `str(float)` (== `repr(float)`). This is CPython's
872/// `format_float_short(..., 'r', ...)` in `pystrtod.c`.
873///
874/// Rust's `{}` cannot stand in: it never switches to exponent notation
875/// (`format!("{}", 1e16_f64)` is `"10000000000000000"`, where Python says
876/// `'1e+16'` — the difference that makes `to_cardinal(1e16)` raise `ValueError`)
877/// and it prints `1`, not `1.0`, for integral floats. The rules, from
878/// `format_float_short`:
879///
880/// * exponent notation iff `decpt <= -4 || decpt > 16` (the `> 16`, not `> 17`,
881///   is deliberate upstream);
882/// * the exponent is `%+.02d`: signed, zero-padded to two digits — `1e-05` but
883///   `1e+100`;
884/// * `Py_DTSF_ADD_DOT_0` appends `.0` to an otherwise integral fixed-notation
885///   result, but never in exponent notation (`repr(1e16) == '1e+16'`);
886/// * `nan` drops its sign, `inf` keeps it.
887fn py_float_repr(value: f64) -> String {
888    if value.is_nan() {
889        return "nan".to_string();
890    }
891    if value.is_infinite() {
892        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
893    }
894    // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0", and to_cardinal strips
895    // that minus textually into a negword.
896    let sign = if value.is_sign_negative() { "-" } else { "" };
897    let (digits, decpt) = shortest_digits(value.abs());
898    let ndigits = digits.len() as i32;
899
900    if decpt <= -4 || decpt > 16 {
901        let exp = decpt - 1;
902        let mut mant = String::from(&digits[..1]);
903        if digits.len() > 1 {
904            mant.push('.');
905            mant.push_str(&digits[1..]);
906        }
907        format!(
908            "{}{}e{}{:02}",
909            sign,
910            mant,
911            if exp < 0 { '-' } else { '+' },
912            exp.abs()
913        )
914    } else if decpt <= 0 {
915        // 0.5 -> decpt 0 -> "0." + "" + "5"; 0.01 -> decpt -1 -> "0." + "0" + "1".
916        format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
917    } else if decpt >= ndigits {
918        // Integral: pad right with zeros, then ADD_DOT_0. 1.0 -> "1" + ".0".
919        format!(
920            "{}{}{}.0",
921            sign,
922            digits,
923            "0".repeat((decpt - ndigits) as usize)
924        )
925    } else {
926        let d = decpt as usize;
927        format!("{}{}.{}", sign, &digits[..d], &digits[d..])
928    }
929}
930
931/// Python's `str(Decimal)` — `_pydecimal.Decimal.__str__` with `eng=False` and
932/// the default context (uppercase `E`).
933///
934/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
935/// with `_exp == -scale`: the shim builds this with `BigDecimal::from_str(str)`,
936/// preserving trailing zeros and negative exponents, so `"1.10"` round-trips as
937/// `(110, 2)` and `"1E+16"` as `(1, -16)`.
938///
939/// # The negative-zero hole
940///
941/// `Decimal` carries `_sign` independently, so `Decimal("-0.0")` is signed zero
942/// and `str()` gives `'-0.0'`; TL then strips that minus textually and answers
943/// "minus zero point zero". A `BigDecimal` cannot represent it — its `int_val`
944/// is a `BigInt` with no negative zero, so `BigDecimal::from_str("-0.0")` has
945/// already discarded the sign before this function runs. We emit `'0.0'` and
946/// drop the negword. The discriminator is the original string, which the
947/// `FloatValue::Decimal` boundary (in `num2words2-py`) does not carry, so the
948/// fix is out of this port's remit. Flagged in the report. Blast radius is
949/// exactly the fixed-notation negative zeros (`-0` … `-0.000000`); beyond that
950/// the `E±n` form raises `ValueError` regardless of sign.
951fn py_decimal_str(value: &BigDecimal) -> String {
952    let (int_val, scale) = value.as_bigint_and_exponent();
953    // i128 so that `-scale` cannot overflow for a pathological i64::MIN scale.
954    let exp = -(scale as i128);
955    let sign = if int_val.is_negative() { "-" } else { "" };
956    let int_digits = int_val.abs().to_string(); // Decimal._int
957    let len = int_digits.len() as i128;
958
959    let leftdigits = exp + len;
960    let dotplace = if exp <= 0 && leftdigits > -6 {
961        leftdigits
962    } else {
963        1
964    };
965
966    let (intpart, fracpart) = if dotplace <= 0 {
967        (
968            "0".to_string(),
969            format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
970        )
971    } else if dotplace >= len {
972        (
973            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
974            String::new(),
975        )
976    } else {
977        let d = dotplace as usize;
978        (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
979    };
980
981    let expstr = if leftdigits == dotplace {
982        String::new()
983    } else {
984        // "%+d" — signed, but *not* zero-padded, unlike repr(float)'s "%+.02d".
985        let d = leftdigits - dotplace;
986        format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
987    };
988
989    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
990}
991
992/// Python's `int(s)`, for the fragments [`LangTl::cardinal_from_str`] hands it —
993/// always ASCII pieces of `str(float)`/`str(Decimal)`. Ports the underscore rule
994/// (`int("1_0") == 10`, `int("1_")` raises) and, crucially, the error message,
995/// which formats the original argument with `%.200R` (i.e. `repr(s)`); every
996/// literal that reaches here is plain ASCII, so `'{}'` matches what `repr`
997/// prints. Raises the same `ValueError` Python raises (`N2WError::Value`).
998fn py_int(s: &str) -> Result<BigInt> {
999    let err = || {
1000        N2WError::Value(format!(
1001            "invalid literal for int() with base 10: '{}'",
1002            s
1003        ))
1004    };
1005    let t = s.trim();
1006    let (negative, body) = match t.strip_prefix('-') {
1007        Some(rest) => (true, rest),
1008        None => (false, t.strip_prefix('+').unwrap_or(t)),
1009    };
1010    // int() permits '_' as a digit separator, but not leading, trailing or doubled.
1011    if body.is_empty()
1012        || body.starts_with('_')
1013        || body.ends_with('_')
1014        || body.contains("__")
1015        || !body.chars().all(|c| c.is_ascii_digit() || c == '_')
1016    {
1017        return Err(err());
1018    }
1019    let digits: String = body.chars().filter(|c| *c != '_').collect();
1020    let n: BigInt = digits.parse().map_err(|_| err())?;
1021    Ok(if negative { -n } else { n })
1022}