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(¢s_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}