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}