num2words2_core/lang_tet.rs
1//! Port of `lang_TET.py` (Tetum), via its `lang_EUR` → `Num2Word_Base` ancestry.
2//!
3//! Shape: **engine**, driven locally. `Num2Word_TET` defines
4//! `mid_numwords`/`low_numwords`/`set_high_numwords` + `merge`, so Python
5//! builds `self.cards` and lets `Num2Word_Base.to_cardinal` run
6//! `splitnum`/`clean`/`merge`. The card table and `MAXVAL` are therefore real
7//! and are exposed through `cards()`/`maxval()`.
8//!
9//! **But the fold cannot use `base::clean`.** TET's `merge` mutates
10//! `self.count` (see "Cross-call mutable state" below), and the `Lang` trait's
11//! `merge` takes `&self`. Interior mutability is not an option either: the
12//! generated PyO3 registry hands out `&'static (dyn Lang + Sync)`, so a
13//! `Cell`/`RefCell` field would make `LangTet` non-`Sync` and fail to compile.
14//! Instead [`LangTet::clean_counted`] mirrors `base::clean` byte for byte with
15//! an explicit `&mut i64` counter threaded through the recursion, and
16//! `to_cardinal`/`to_ordinal` are overridden to drive it. `base::splitnum` is
17//! reused unchanged — it only reads `cards` and never calls `merge`.
18//! The trait's `merge` is consequently left at its `unimplemented!()` default;
19//! nothing on the TET path reaches it.
20//!
21//! Setup notes (chased through the inheritance chain):
22//! * `setup()` calls `super().setup()` (which builds the full EUR Latin-prefix
23//! `high_numwords`) and then **throws it away**, reassigning
24//! `high_numwords = gen_high_numwords([], [], ["kuatr","tr","b","m"])`.
25//! With empty `units`/`tens` the comprehension yields `[]`, so the result is
26//! just the `lows` list. The EUR elision table is dead code here.
27//! * `Num2Word_EUR.set_high_numwords`: `cap = 3 + 6*4 = 27`,
28//! `zip(high, range(27, 3, -6))` → 27, 21, 15, 9. `GIGA_SUFFIX` is `None`
29//! so the `10**n` entries are skipped entirely; only
30//! `cards[10**(n-3)] = word + "iliaun"` lands → 10^24 kuatriliaun,
31//! 10^18 triliaun, 10^12 biliaun, 10^6 miliaun. Hence `MAXVAL = 10^27`.
32//! * `is_title` is never set, so `title()` is the identity. It is *not* a
33//! whitespace normaliser here — `exclude_title` (["resin","vírgula",
34//! "menus"]) is carried for fidelity but is unreachable.
35//! * `self.hundreds` is built in `setup()` and **never read** by any method.
36//!
37//! # Cross-call mutable state (IMPORTANT — flagged for the dispatcher)
38//!
39//! `setup()` sets `self.count = 0` and `merge` increments it, but **nothing
40//! ever resets it**. `num2words2/__init__.py` instantiates
41//! `Num2Word_TET()` once at import, so `self.count` is effectively
42//! process-global and monotonically increasing across every call.
43//!
44//! The counter gates one branch: the first time `merge` folds a value whose
45//! decimal middle digits are all "0" (e.g. 101), it returns without the "ho"
46//! infix and bumps `count` to 1; **every** later such fold — in any later call
47//! — returns "ho …" instead. So Python's answer depends on call history.
48//!
49//! This port models `count` as a **per-call local starting at 0** (equivalent
50//! to a fresh converter instance per call). Justification, all verified
51//! against the interpreter:
52//! * It reproduces all 305 in-scope corpus rows exactly.
53//! * The state is unobservable for every corpus input: presetting `count` to
54//! 0/1/2/7/99 gives byte-identical output for all of them, because
55//! `ho_result`/`remove_ho` strip the "ho" back out again.
56//! * It equals what a fresh Python process emits for its first call.
57//!
58//! It is **not** unobservable in general. Inputs like 100101, 101001,
59//! 1000101, 1001001, 10000101, … keep the "ho" (their `remove_ho` guard
60//! short-circuits, see the `value_str[:-4]` quirk below), so real Python gives
61//! "rihun atus ida atus ida ida" on a cold instance and
62//! "rihun atus ida ho atus ida ida" once warmed. None of those values are in
63//! the corpus. **The Python dispatcher must not be allowed to compare against
64//! a warmed instance for such inputs.**
65//!
66//! # Faithfully reproduced Python quirks
67//!
68//! This is a port, not a rewrite. All of the following are odd but are exactly
69//! what Python emits:
70//!
71//! 1. `merge` renders 100 as "atus ida" (`ntext + " " + ctext`, i.e.
72//! "hundred one"), so 101 is "atus ida ida" and 1001 "rihun ida ida". The
73//! `hundreds` dict that would give "atus rua" etc. is never consulted.
74//! 2. `remove_ho`'s guard is `value_str[:-4].endswith("0")` — a *string* test
75//! on the digits with the last four dropped. For 100001 that is "10", which
76//! ends in "0", so the whole strip block is skipped. It is a digit-pattern
77//! heuristic, not arithmetic, and is reproduced literally.
78//! 3. `ho_result` does `result[3:]` to drop a leading "ho", assuming the third
79//! character is the space that `merge`'s "ho %s %s" put there.
80//! 4. `ho_result` replaces `low + "iliaun" + " ho"` for lows in order
81//! ["kuatr","tr","b","m"]. The order is load-bearing: "kuatriliaun" *ends
82//! with* "triliaun", so the "kuatr" pass must consume it before the "tr"
83//! pass sees it. Preserved exactly.
84//! 5. `to_ordinal` inlines a verbatim copy of `Num2Word_Base.clean` rather than
85//! calling it — same semantics, so [`LangTet::clean_counted`] serves both.
86//! 6. `to_ordinal` computes `words_split = words.split()` **once**, then keeps
87//! reassigning `words` while continuing to read the now-stale `words_split`
88//! for `word_first` and for the final `len(str(num)) > 3` block.
89//! 7. In that final block Python writes `"haat" in words_split[-1:]` — a slice,
90//! so this is *list* membership (exact equality against the last word), not
91//! the substring test used a few lines earlier on `second_word`. The two
92//! read alike and behave differently; both are reproduced as written.
93//! 8. `merge`'s dead `self.count += 0` in the `count >= 1` arm is a no-op and
94//! is simply dropped.
95//! 9. `to_ordinal` never checks `MAXVAL` (only `to_cardinal` does), so ordinals
96//! above 10^27 are rendered rather than raising `OverflowError`.
97//!
98//! # Error variants
99//!
100//! * negative `to_ordinal`/`to_ordinal_num` → `TypeError` via
101//! `Num2Word_Base.verify_ordinal` → [`N2WError::Type`].
102//! * `to_cardinal(v)` with `abs(v) >= 10^27` → `OverflowError` →
103//! [`N2WError::Overflow`].
104//! * `to_ordinal`'s `words_split[0]` would raise `IndexError` on empty `words`
105//! → [`N2WError::Index`]. Believed unreachable (no card word is empty), but
106//! modelled rather than silently swallowed.
107//! * `to_currency` with `abs(val) >= 10**26` → `decimal.InvalidOperation` →
108//! [`N2WError::Custom`]. See [`decimal_prec_limit`].
109
110use crate::base::{set_low_numwords, set_mid_numwords, splitnum, Cards, Lang, N2WError, Node, Result};
111use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
112use crate::floatpath::FloatValue;
113use bigdecimal::BigDecimal;
114use num_bigint::BigInt;
115use num_integer::Integer;
116use num_traits::{One, Signed, Zero};
117use std::collections::HashMap;
118use std::sync::OnceLock;
119
120/// `merge`'s "these numbers get a -k suffix" table, from `to_ordinal`.
121/// Note the gaps: 1, 4 and 6 are deliberately absent (handled separately).
122const ORD_SUFFIX_K: [i64; 15] = [90, 80, 70, 60, 50, 40, 30, 20, 10, 9, 8, 7, 5, 3, 2];
123/// Numbers that take the "da" prefix but no "k" suffix.
124const ORD_NO_K: [i64; 2] = [6, 4];
125/// Hundreds taking "dah" + words + "k".
126const ORD_HUNDREDS_K: [i64; 7] = [900, 800, 700, 500, 300, 200, 100];
127/// Hundreds taking "dah" + words, no "k".
128const ORD_HUNDREDS_NO_K: [i64; 2] = [600, 400];
129
130/// `ho_result`'s `lows`, in the order Python iterates them. Order matters —
131/// see quirk 4 in the module docs.
132const LOWS: [&str; 4] = ["kuatr", "tr", "b", "m"];
133const MEGA_SUFFIX: &str = "iliaun";
134
135/// `Num2Word_TET.CURRENCY_FORMS`.
136///
137/// TET declares its **own** class-level dict, so — unlike the 16 classes that
138/// read `Num2Word_EUR`'s — it is *not* touched by `Num2Word_EN.__init__`'s
139/// in-place mutation of the shared EUR table. Here `lang_TET.py`'s source
140/// really is what runs, confirmed against the live interpreter: five codes and
141/// no more. EUR reads `("euro", "euros")` because TET spells that pair out
142/// itself, not because English rewrote it; GBP keeps TET's own
143/// `("pound sterling", "pound sterling")` / `("pence", "pence")` rather than
144/// EUR's `("penny", "pence")`.
145///
146/// Consequently every code EN adds to the shared dict (JPY, KWD, BHD, INR,
147/// CNY, CHF, …) raises NotImplementedError for TET — which is exactly what the
148/// 60 error rows in the corpus expect.
149///
150/// Both forms of every entry are identical except EUR's, so `pluralize`'s
151/// singular/plural choice is only observable on EUR.
152fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
153 // Module-level `DOLLAR` / `CENTS` in lang_TET.py, shared by AUD/CAD/USD.
154 const DOLLAR: [&str; 2] = ["dolar", "dolar"];
155 const CENTS: [&str; 2] = ["sentavu", "sentavu"];
156
157 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
158 m.insert("AUD", CurrencyForms::new(&DOLLAR, &CENTS));
159 m.insert("CAD", CurrencyForms::new(&DOLLAR, &CENTS));
160 m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &CENTS));
161 m.insert(
162 "GBP",
163 CurrencyForms::new(&["pound sterling", "pound sterling"], &["pence", "pence"]),
164 );
165 m.insert("USD", CurrencyForms::new(&DOLLAR, &CENTS));
166 m
167}
168
169/// `10**28` — the point where `Decimal.__mod__` gives up.
170///
171/// `to_currency` opens with `(Decimal(str(val)) * 100) % 1`, which runs under
172/// the *default* decimal context (`prec=28`), not exact arithmetic.
173/// `Decimal.__mod__` raises `InvalidOperation(DivisionImpossible)` as soon as
174/// the integer quotient needs more than `prec` digits, and dividing by 1 makes
175/// that quotient `trunc(decimal_val * 100)`. So every `abs(val) >= 10**26`
176/// raises — int, float and Decimal alike — even though TET's own `MAXVAL` is
177/// 10**27 and `to_cardinal` would happily render the number.
178///
179/// The raise happens *before* the `CURRENCY_FORMS` lookup, so it beats the
180/// unknown-code branch too: `to_currency(10**26, "JPY")` is InvalidOperation,
181/// not NotImplementedError. Verified against the interpreter.
182fn decimal_prec_limit() -> &'static BigDecimal {
183 static LIMIT: OnceLock<BigDecimal> = OnceLock::new();
184 LIMIT.get_or_init(|| BigDecimal::from(BigInt::from(10).pow(28)))
185}
186
187/// Round to the default decimal context's 28 significant digits, ROUND_HALF_EVEN.
188///
189/// `Decimal.__mul__` rounds its result to `prec` significant digits, so
190/// `decimal_val * 100` is only exact while the operand carries at most 28 of
191/// them. Only a caller-supplied `Decimal` ever exceeds that: a float's `repr`
192/// yields at most 17 significant digits, and an int big enough to overflow 28
193/// raises at the `% 1` step regardless of how the product was rounded.
194///
195/// `Decimal("1.5000000000000000000000000001")` is the case that bites. Python
196/// rounds the product to `150.0000000000000000000000000`, so `% 1` is 0 and
197/// `has_fractional_cents` comes out **False** — "dolar ida sentavu lima nulu".
198/// Exact `BigDecimal` arithmetic would say True and take the fractional
199/// branch, so the context rounding is reproduced rather than idealised away.
200///
201/// Only the *value* is reproduced, not Python's coefficient/exponent
202/// normalisation: the one caller merely compares a magnitude and tests
203/// integrality, and both are exponent-invariant.
204fn decimal_context_round(v: &BigDecimal) -> BigDecimal {
205 const PREC: u64 = 28;
206
207 let digits = v.digits();
208 if digits <= PREC {
209 return v.clone();
210 }
211 let diff = digits - PREC;
212 let (int_val, scale) = v.as_bigint_and_exponent();
213 let p = BigInt::from(10).pow(diff as u32);
214
215 // BigInt's div_rem truncates toward zero, so round the magnitude and
216 // reapply the sign — ROUND_HALF_EVEN is symmetric about zero.
217 let negative = int_val.is_negative();
218 let (mut q, r) = int_val.abs().div_rem(&p);
219 let twice = &r * BigInt::from(2);
220 if twice > p || (twice == p && q.is_odd()) {
221 q += BigInt::one();
222 }
223 if negative {
224 q = -q;
225 }
226 BigDecimal::new(q, scale - diff as i64)
227}
228
229pub struct LangTet {
230 cards: Cards,
231 maxval: BigInt,
232 exclude_title: Vec<String>,
233 currency_forms: HashMap<&'static str, CurrencyForms>,
234}
235
236impl Default for LangTet {
237 fn default() -> Self {
238 Self::new()
239 }
240}
241
242impl LangTet {
243 pub fn new() -> Self {
244 let mut cards = Cards::new();
245
246 // Num2Word_EUR.set_high_numwords over
247 // high = gen_high_numwords([], [], ["kuatr","tr","b","m"]) == the lows.
248 // cap = 3 + 6*len(high) = 27; zip(high, range(cap, 3, -6)).
249 // GIGA_SUFFIX is None -> no 10**n card; MEGA_SUFFIX -> 10**(n-3).
250 let high = LOWS;
251 let mut n: i64 = 3 + 6 * high.len() as i64;
252 for word in high.iter() {
253 if n <= 3 {
254 break; // mirrors range(cap, 3, -6) running out
255 }
256 cards.insert(
257 BigInt::from(10u8).pow((n - 3) as u32),
258 format!("{}{}", word, MEGA_SUFFIX),
259 );
260 n -= 6;
261 }
262
263 set_mid_numwords(
264 &mut cards,
265 &[
266 (1000, "rihun"),
267 (100, "atus"),
268 (90, "sia nulu"),
269 (80, "ualu nulu"),
270 (70, "hitu nulu"),
271 (60, "neen nulu"),
272 (50, "lima nulu"),
273 (40, "haat nulu"),
274 (30, "tolu nulu"),
275 (20, "rua nulu"),
276 ],
277 );
278 set_low_numwords(
279 &mut cards,
280 &[
281 "sanulu", "sia", "ualu", "hitu", "neen", "lima", "haat", "tolu", "rua", "ida",
282 "mamuk",
283 ],
284 );
285
286 // MAXVAL = 1000 * list(self.cards.keys())[0] == 1000 * 10^24 == 10^27.
287 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
288
289 LangTet {
290 cards,
291 maxval,
292 // Carried for fidelity; is_title is always False so title() never
293 // consults it.
294 exclude_title: vec!["resin".into(), "vírgula".into(), "menus".into()],
295 // Built once here, never per call — `to_currency`/`to_cheque` only
296 // ever read it.
297 currency_forms: build_currency_forms(),
298 }
299 }
300
301 /// `Num2Word_TET.merge`, with `self.count` passed explicitly.
302 ///
303 /// Python names the args `curr`/`next` and unpacks `curr + next` (tuple
304 /// concatenation) into `ctext, cnum, ntext, nnum`.
305 fn merge_counted(
306 &self,
307 curr: (&str, &BigInt),
308 next: (&str, &BigInt),
309 count: &mut i64,
310 ) -> (String, BigInt) {
311 let (ctext, cnum) = curr;
312 let (ntext, nnum) = next;
313 let ten = BigInt::from(10);
314 let hundred = BigInt::from(100);
315
316 if cnum.is_one() && nnum < &hundred {
317 return (ntext.to_string(), nnum.clone());
318 }
319
320 if nnum < cnum {
321 if nnum < &ten {
322 let sum = cnum + nnum;
323 // Python: value_str = str(cnum + nnum); if int(value_str) > 100
324 if sum > hundred {
325 let chars: Vec<char> = sum.to_string().chars().collect();
326 // value_str[1:-1] — the middle digits. Always non-empty
327 // here (sum > 100 => at least 3 digits), but guard anyway.
328 let zero_list: &[char] = if chars.len() >= 2 {
329 &chars[1..chars.len() - 1]
330 } else {
331 &[]
332 };
333 // Python's all() over an empty slice is True.
334 let all_zero = zero_list.iter().all(|c| *c == '0');
335 if all_zero {
336 if *count >= 1 {
337 // Python does a no-op `self.count += 0` here.
338 return (format!("ho {} {}", ctext, ntext), sum);
339 }
340 *count += 1;
341 return (format!("{} {}", ctext, ntext), sum);
342 }
343 }
344 return (format!("{} resin {}", ctext, ntext), sum);
345 } else {
346 return (format!("{} {}", ctext, ntext), cnum + nnum);
347 }
348 }
349
350 (format!("{} {}", ntext, ctext), cnum * nnum)
351 }
352
353 /// `Num2Word_Base.clean`, mirrored with an explicit merge counter.
354 ///
355 /// Identical in structure to `base::clean` (including appending the tail
356 /// `val[2:]` as a *nested list* rather than splicing it); the only reason
357 /// it is duplicated here is that `merge` needs `&mut count`, which the
358 /// `Lang` trait's `&self` signature cannot carry. `to_ordinal` inlines this
359 /// same loop in Python, so both entry points share it.
360 fn clean_counted(&self, val: Vec<Node>, count: &mut i64) -> Node {
361 let mut val = val;
362 while val.len() != 1 {
363 let mut out: Vec<Node> = Vec::new();
364 let both_leaf = val.len() >= 2
365 && matches!(&val[0], Node::Leaf(..))
366 && matches!(&val[1], Node::Leaf(..));
367 if both_leaf {
368 let (lt, ln) = match &val[0] {
369 Node::Leaf(t, n) => (t.clone(), n.clone()),
370 _ => unreachable!(),
371 };
372 let (rt, rn) = match &val[1] {
373 Node::Leaf(t, n) => (t.clone(), n.clone()),
374 _ => unreachable!(),
375 };
376 let (mt, mn) = self.merge_counted((<, &ln), (&rt, &rn), count);
377 out.push(Node::Leaf(mt, mn));
378 if val.len() > 2 {
379 out.push(Node::List(val[2..].to_vec()));
380 }
381 } else {
382 for elem in val.into_iter() {
383 match elem {
384 Node::List(inner) => {
385 if inner.len() == 1 {
386 out.push(inner.into_iter().next().unwrap());
387 } else {
388 out.push(self.clean_counted(inner, count));
389 }
390 }
391 leaf => out.push(leaf),
392 }
393 }
394 }
395 val = out;
396 }
397 val.into_iter().next().unwrap()
398 }
399
400 /// `Num2Word_TET.ho_result`.
401 fn ho_result(&self, result: &str, value: &BigInt) -> String {
402 let mut result = result.to_string();
403
404 // Python: index = result.find("ho"); count_ho = result.count("ho")
405 // if index != -1 and count_ho >= 1
406 // The second test is implied by the first; both reduce to "contains".
407 if !result.contains("ho") {
408 return result;
409 }
410
411 // Python: value_str = len(str(value)) — a *length*, and str() keeps the
412 // minus sign for negatives (to_cardinal passes the signed original).
413 let value_len = value.to_string().chars().count();
414 if result.contains("rihun") && value_len > 7 {
415 result = result.replace("rihun ho", "ho rihun");
416 }
417
418 for low in LOWS.iter() {
419 result = result.replace(
420 &format!("{}{} ho", low, MEGA_SUFFIX),
421 &format!("ho {}{}", low, MEGA_SUFFIX),
422 );
423 }
424
425 if result.starts_with("ho") {
426 // Python: result[3:] — drops "ho" plus the space merge emitted.
427 // Character-indexed, not byte-indexed.
428 result = result.chars().skip(3).collect();
429 }
430 result
431 }
432
433 /// `Num2Word_TET.remove_ho`.
434 fn remove_ho(&self, result: &str, value: &BigInt) -> String {
435 let value_str = value.to_string();
436 let mut result = self.ho_result(result, value);
437
438 let vchars: Vec<char> = value_str.chars().collect();
439 // Python: end_value = value_str[:-4] ("" when len <= 4).
440 let end_value: String = vchars[..vchars.len().saturating_sub(4)].iter().collect();
441 let end_true = end_value.ends_with('0');
442
443 if !end_true && value > &BigInt::from(100) {
444 // value > 100 guarantees at least 3 chars, so both indexes are safe.
445 let last = vchars[vchars.len() - 1];
446 let second_last = vchars[vchars.len() - 2];
447 if last != '0' && second_last == '0' {
448 result = result.replace("ho", "");
449 result = result.replace(" ", " ");
450 }
451 }
452 result
453 }
454
455 /// `Num2Word_TET.ho_result`, parameterised by the value's decimal *string*
456 /// length rather than a `BigInt`, for the float/Decimal path.
457 ///
458 /// Identical in structure to [`LangTet::ho_result`]; the only difference is
459 /// that `str(value)` on the float path carries a decimal point (and a sign),
460 /// so `len(str(value))` and the `value_str > 7` test see those characters
461 /// too. Python computes `value_str = len(str(value))` — a *length* — so the
462 /// caller passes `value_str.chars().count()`.
463 fn ho_result_str(&self, result: &str, value_len: usize) -> String {
464 let mut result = result.to_string();
465
466 // Python: `if index != -1 and count_ho >= 1` — i.e. "ho" present.
467 if !result.contains("ho") {
468 return result;
469 }
470
471 if result.contains("rihun") && value_len > 7 {
472 result = result.replace("rihun ho", "ho rihun");
473 }
474
475 for low in LOWS.iter() {
476 result = result.replace(
477 &format!("{}{} ho", low, MEGA_SUFFIX),
478 &format!("ho {}{}", low, MEGA_SUFFIX),
479 );
480 }
481
482 if result.starts_with("ho") {
483 result = result.chars().skip(3).collect();
484 }
485 result
486 }
487
488 /// `Num2Word_TET.remove_ho`, driven by `str(value)` / `value > 100` for the
489 /// float/Decimal path.
490 ///
491 /// TET's `to_cardinal` calls this on the whole float cardinal string with
492 /// the ORIGINAL float/Decimal value. That matters: the default float path
493 /// already ran `remove_ho` on the integer part via `to_cardinal(pre)`, but
494 /// that inner pass used the *integer* `pre`. This outer pass uses `value`,
495 /// whose `str()` carries the decimal point — so `value_str[-2] == "0"` can
496 /// match a *fractional* digit (e.g. `.01`, `.005`, `.05`) and strip a "ho"
497 /// the inner pass left in place. `604603031.01` is such a case: the integer
498 /// rendering keeps "…haat ho rihun…", and only this outer pass removes it.
499 fn remove_ho_value(&self, result: &str, value_str: &str, gt_100: bool) -> String {
500 let vchars: Vec<char> = value_str.chars().collect();
501 let mut result = self.ho_result_str(result, vchars.len());
502
503 // Python: end_value = value_str[:-4]; end_true = end_value.endswith("0")
504 let end_value: String = vchars[..vchars.len().saturating_sub(4)].iter().collect();
505 let end_true = end_value.ends_with('0');
506
507 if !end_true && gt_100 {
508 // Python indexes value_str[-1] and value_str[-2] unconditionally
509 // under `value > 100`; a decimal string for such a value always has
510 // at least two chars, but guard rather than risk a panic.
511 if vchars.len() >= 2 {
512 let last = vchars[vchars.len() - 1];
513 let second_last = vchars[vchars.len() - 2];
514 if last != '0' && second_last == '0' {
515 result = result.replace("ho", "");
516 result = result.replace(" ", " ");
517 }
518 }
519 }
520 result
521 }
522
523 /// `Num2Word_Base.verify_ordinal`. The float test is unreachable for
524 /// integer input; only the negative test can fire.
525 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
526 if value.is_negative() {
527 return Err(N2WError::Type(format!(
528 "Cannot treat negative num {} as ordinal.",
529 value
530 )));
531 }
532 Ok(())
533 }
534
535 /// `Num2Word_Base.to_cardinal` for integral input, with the merge counter
536 /// threaded through. TET's own `to_cardinal` wraps this in `remove_ho`.
537 fn cardinal_words(&self, value: &BigInt, count: &mut i64) -> Result<String> {
538 let mut out = String::new();
539 let mut v = value.clone();
540 if v.is_negative() {
541 v = v.abs();
542 // negword is "menus "; Python emits "%s " % negword.strip().
543 out = format!("{} ", self.negword().trim());
544 }
545
546 if v >= self.maxval {
547 return Err(N2WError::Overflow(format!(
548 "abs({}) must be less than {}.",
549 v, self.maxval
550 )));
551 }
552
553 let tree = splitnum(self, &v).ok_or_else(|| {
554 N2WError::Overflow(format!("abs({}) must be less than {}.", v, self.maxval))
555 })?;
556 let words = match self.clean_counted(tree, count) {
557 Node::Leaf(t, _) => t,
558 Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
559 };
560 Ok(self.title(&format!("{}{}", out, words)))
561 }
562}
563
564impl Lang for LangTet {
565 /// This language's own `to_currency(currency=...)` default,
566 /// read from the live Python signature. Only 44 of 156 use EUR.
567 fn default_currency(&self) -> &str {
568 "USD"
569 }
570
571 fn cards(&self) -> &Cards {
572 &self.cards
573 }
574 fn maxval(&self) -> &BigInt {
575 &self.maxval
576 }
577 fn negword(&self) -> &str {
578 "menus "
579 }
580 fn pointword(&self) -> &str {
581 "vírgula"
582 }
583 fn exclude_title(&self) -> &[String] {
584 &self.exclude_title
585 }
586
587 // `merge` is intentionally left at the trait default. TET's merge needs
588 // `&mut self.count`, which `&self` cannot express, so the fold runs through
589 // `clean_counted`/`merge_counted` instead. Nothing on this path calls it.
590
591 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
592 // Python reads the process-global `self.count`; we start fresh per call.
593 // See "Cross-call mutable state" in the module docs.
594 let mut count: i64 = 0;
595 let result = self.cardinal_words(value, &mut count)?;
596 // Python passes the *original* (possibly negative) value here.
597 Ok(self.remove_ho(&result, value))
598 }
599
600 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
601 self.verify_ordinal(value)?;
602 let mut count: i64 = 0;
603 // Python's `out` is initialised to "" and never assigned again.
604 let out = "";
605
606 // Note: to_ordinal does NOT check MAXVAL. splitnum only returns None
607 // for negatives, which verify_ordinal has already rejected; Python
608 // would hit `len(None)` -> TypeError, so model that shape.
609 let tree = splitnum(self, value).ok_or_else(|| {
610 N2WError::Type("object of type 'NoneType' has no len()".into())
611 })?;
612 let (mut words, num) = match self.clean_counted(tree, &mut count) {
613 Node::Leaf(t, n) => (t, n),
614 Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
615 };
616
617 words = self.remove_ho(&words, value);
618
619 let in_list = |ks: &[i64]| ks.iter().any(|k| num == BigInt::from(*k));
620
621 // Sequential `if`s in Python, not elif — several can fire in turn.
622 if in_list(&ORD_SUFFIX_K) {
623 words = format!("da{}k", words);
624 }
625 if in_list(&ORD_NO_K) {
626 words = format!("da{}", words);
627 }
628 if num.is_one() {
629 words = "dahuluk".to_string();
630 }
631 if in_list(&ORD_HUNDREDS_K) {
632 words = format!("dah{}k", words);
633 }
634 if in_list(&ORD_HUNDREDS_NO_K) {
635 words = format!("dah{}", words);
636 }
637
638 // Computed ONCE; `words` keeps changing underneath it (quirk 6).
639 let words_split: Vec<String> = words.split_whitespace().map(|s| s.to_string()).collect();
640
641 if words_split.len() >= 3 && num < BigInt::from(100) {
642 let first_word = format!("da{}", words_split[0]);
643 let second_word = words_split[1..].join(" ");
644 // Substring test (contrast with the list test below).
645 if second_word.contains("haat") || second_word.contains("neen") {
646 words = format!("{} {}", first_word, second_word);
647 } else {
648 words = format!("{} {}k", first_word, second_word);
649 }
650 }
651
652 // Python indexes words_split[0] unconditionally -> IndexError if empty.
653 let first = words_split
654 .first()
655 .ok_or_else(|| N2WError::Index("list index out of range".into()))?;
656 let word_first = format!("dah{}", first);
657 if word_first == "dahatus" && words_split.len() >= 3 {
658 let word_second = words_split[1..].join(" ");
659 if word_second.contains("haat") || word_second.contains("neen") {
660 words = format!("{} {}", word_first, word_second);
661 } else {
662 words = format!("{} {}k", word_first, word_second);
663 }
664 }
665
666 if num.to_string().chars().count() > 3 {
667 // Python: `"haat" in words_split[-1:]` — a LIST slice, so this is
668 // exact equality against the last word, not a substring test.
669 let last_is = |w: &str| words_split.last().map(|s| s.as_str() == w).unwrap_or(false);
670 if last_is("haat") || last_is("neen") {
671 words = format!("da{}", words);
672 } else {
673 words = format!("da{}k", words);
674 }
675 }
676
677 Ok(self.title(&format!("{}{}", out, words)))
678 }
679
680 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
681 self.verify_ordinal(value)?;
682 Ok(format!("{}º", value))
683 }
684
685 fn to_year(&self, value: &BigInt) -> Result<String> {
686 if value.is_negative() {
687 return Ok(format!("{} antes Kristu", self.to_cardinal(&value.abs())?));
688 }
689 self.to_cardinal(value)
690 }
691
692 /// `to_ordinal(float/Decimal)`: `verify_ordinal` raises TypeError for a
693 /// non-integral value first; whole values run the same splitnum/suffix
694 /// pipeline as the integer path (numeric comparisons make 5.0 and 5
695 /// indistinguishable there), and the negative TypeError comes from
696 /// `to_ordinal(BigInt)`'s own verify.
697 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
698 match value.as_whole_int() {
699 Some(i) => self.to_ordinal(&i),
700 None => Err(N2WError::Type(
701 "Cannot treat float as ordinal.".into(),
702 )),
703 }
704 }
705
706 /// `to_ordinal_num(float/Decimal)`: same `verify_ordinal`, then
707 /// `"%sº" % value` with Python's `str(value)` ("5.0º", "-0.0º").
708 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
709 match value.as_whole_int() {
710 Some(i) => {
711 if i.is_negative() {
712 return Err(N2WError::Type(format!(
713 "Cannot treat negative num {} as ordinal.",
714 repr_str
715 )));
716 }
717 Ok(format!("{}º", repr_str))
718 }
719 None => Err(N2WError::Type(
720 "Cannot treat float as ordinal.".into(),
721 )),
722 }
723 }
724
725 /// `to_year(float/Decimal)`: `if val < 0` (numeric) →
726 /// `to_cardinal(abs(val)) + " antes Kristu"`, keeping the float grammar
727 /// ("ida vírgula lima antes Kristu" for -1.5).
728 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
729 let negative = match value {
730 FloatValue::Float { value, .. } => *value < 0.0,
731 FloatValue::Decimal { value, .. } => value.is_negative(),
732 };
733 if negative {
734 let abs = match value {
735 FloatValue::Float { value, precision } => FloatValue::Float {
736 value: value.abs(),
737 precision: *precision,
738 },
739 FloatValue::Decimal { value, precision } => FloatValue::Decimal {
740 value: value.abs(),
741 precision: *precision,
742 },
743 };
744 Ok(format!(
745 "{} antes Kristu",
746 self.cardinal_float_entry(&abs, None)?
747 ))
748 } else {
749 self.cardinal_float_entry(value, None)
750 }
751 }
752
753 /// `Num2Word_TET.to_cardinal` on the non-integral branch.
754 ///
755 /// TET does not override `to_cardinal_float`; it overrides `to_cardinal`,
756 /// which for non-integral input runs
757 /// `remove_ho(super().to_cardinal(value), value)`. `super().to_cardinal`
758 /// (`Num2Word_Base.to_cardinal`) delegates non-integral input straight to
759 /// `Num2Word_Base.to_cardinal_float` — the default float path — so the
760 /// port is `remove_ho(default_to_cardinal_float(value), value)`.
761 ///
762 /// The `num2words` dispatcher routes only *non-whole* floats here (whole
763 /// floats like `1.0` satisfy `int(value) == value` and take the integer
764 /// `to_cardinal` path instead), so this method never sees a whole value —
765 /// matching the 26 languages that inherit the bare default unchanged.
766 ///
767 /// Why the wrapper is not redundant: the default path already applies
768 /// `remove_ho` to the integer part inside `to_cardinal(pre)`, but with the
769 /// integer `pre`. This outer pass re-applies it with the ORIGINAL value,
770 /// whose `str()` carries the fractional digits — verified against the live
771 /// interpreter to be the only thing that strips a stray "ho" out of e.g.
772 /// `604603031.01`. On every corpus row the string has no "ho" and the pass
773 /// is a no-op, so it changes nothing there.
774 fn to_cardinal_float(
775 &self,
776 value: &FloatValue,
777 precision_override: Option<u32>,
778 ) -> Result<String> {
779 let result =
780 crate::floatpath::default_to_cardinal_float(self, value, precision_override)?;
781
782 // `str(value)` and `value > 100`, from the original float/Decimal.
783 // f64's `{}` is shortest-round-trip (same contract as Python's repr),
784 // and BigDecimal's `{}` preserves scale, so both reproduce the digit
785 // string `remove_ho` slices. `precision_override` does not affect this
786 // string — Python reads `str(value)`, not the rescaled precision.
787 let (value_str, gt_100) = match value {
788 FloatValue::Float { value, .. } => (format!("{}", value), *value > 100.0),
789 FloatValue::Decimal { value, .. } => {
790 (format!("{}", value), value > &BigDecimal::from(100))
791 }
792 };
793 Ok(self.remove_ho_value(&result, &value_str, gt_100))
794 }
795
796 // ---- currency -------------------------------------------------------
797 //
798 // TET overrides `to_currency` wholesale and inherits everything else:
799 // `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse` from
800 // `Num2Word_Base`, `pluralize` from `Num2Word_EUR`. The trait defaults
801 // already mirror all of those, so only `pluralize`, the class name, the
802 // forms table and `to_currency` itself appear here.
803 //
804 // `currency_precision` is deliberately NOT overridden: TET never declares
805 // `CURRENCY_PRECISION`, and `Num2Word_EN.__init__` *rebinds* rather than
806 // mutates it, so nothing leaks in. `.get(code, 100)` is therefore always
807 // 100 — the trait default. TET's `to_currency` hardcodes 100 anyway, and
808 // it implements no 3-decimal (KWD/BHD) or 0-decimal (JPY) currency: those
809 // codes are absent from the table and raise instead.
810 //
811 // `currency_adjective` is likewise not overridden. TET *does* inherit
812 // `Num2Word_EUR.CURRENCY_ADJECTIVES`, but its `to_currency` signature is
813 // `(val, currency="USD", cents=True)` — there is no `adjective` parameter
814 // and no `prefix_currency` call anywhere on the path, so the table is dead
815 // data. `to_cheque` never consults adjectives either. Wiring it up would
816 // advertise a behaviour Python does not have.
817
818 fn lang_name(&self) -> &str {
819 "Num2Word_TET"
820 }
821
822 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
823 self.currency_forms.get(code)
824 }
825
826 /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
827 ///
828 /// Python indexes the tuple directly, so a one-form entry with `n != 1`
829 /// would raise IndexError. Every TET entry has two forms, so this is
830 /// unreachable — mapped to `Index` rather than panicking, as in `lang_en`.
831 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
832 let form = if n.is_one() { 0 } else { 1 };
833 forms
834 .get(form)
835 .cloned()
836 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
837 }
838
839 /// `Num2Word_TET.to_currency`.
840 ///
841 /// Python's signature is `(val, currency="USD", cents=True)` — it has
842 /// neither `separator` nor `adjective`, hence the two ignored parameters
843 /// (and hence no generated `default_separator`: there is no separator to
844 /// default). TET emits no separator at all; the cents segment is joined
845 /// with a plain space.
846 ///
847 /// # Faithfully reproduced Python quirks
848 ///
849 /// 1. **Word order is inverted relative to `Num2Word_Base.to_currency`.**
850 /// Base emits `money_str` then the currency name ("two dollars"); TET
851 /// emits the currency name *first* — `"%s%s %s" % (minus_str,
852 /// curr_name, money_str)` → "dolar rua". The corpus confirms it.
853 /// 2. The `is_integer_input` early return is **dead code**. It formats
854 /// identically to the `right == 0` branch below it, and for an `int`
855 /// `parse_currency_parts(..., is_int_with_cents=False)` always returns
856 /// `right == 0`, so control could never reach one without the other
857 /// agreeing. Kept because it is what Python executes.
858 /// 3. `parse_currency_parts` is called with the default
859 /// `keep_precision=False` **even when `has_fractional_cents` is true**,
860 /// so `right` is always the ROUND_HALF_UP-quantized whole-cent count.
861 /// The "fractional" branch then re-derives cents as `right / 100.0` —
862 /// from the already-rounded value. 1.011 USD is therefore
863 /// "dolar ida mamuk vírgula mamuk ida sentavu" (…0.01…), not 1.1 cents.
864 /// 4. `pluralize(1, cr2)` — hardcoded singular — in the fractional branch,
865 /// and the operands swap places: whole cents render as
866 /// `pluralize(right, cr2)` then `cents_str`, fractional cents as
867 /// `cents_str` then `pluralize(1, cr2)`.
868 /// 5. The `cents` flag is ignored on the fractional path: `cents=False`
869 /// still spells 1.011 out in words, because the
870 /// `has_fractional_cents and right > 0` test precedes it.
871 /// 6. `hasattr(self, "to_cardinal_float")` is always true —
872 /// `Num2Word_Base` defines it — so the `else` arm of that ternary is
873 /// unreachable and is not modelled.
874 fn to_currency(
875 &self,
876 val: &CurrencyValue,
877 currency: &str,
878 cents: bool,
879 _separator: Option<&str>,
880 _adjective: bool,
881 ) -> Result<String> {
882 // decimal_val = Decimal(str(val)) — the *signed* value; TET takes no
883 // abs here, unlike several sibling languages.
884 let decimal_val: BigDecimal = match val {
885 CurrencyValue::Int(i) => BigDecimal::from(i.clone()),
886 CurrencyValue::Decimal { value, .. } => value.clone(),
887 };
888
889 // has_fractional_cents = (decimal_val * 100) % 1 != 0
890 //
891 // The 100 is hardcoded in Python; CURRENCY_PRECISION is never consulted
892 // on this path. Both the context rounding and the DivisionImpossible
893 // limit fire before the CURRENCY_FORMS lookup below, matching Python's
894 // statement order.
895 let scaled = decimal_context_round(&(&decimal_val * BigDecimal::from(100)));
896 if scaled.abs() >= *decimal_prec_limit() {
897 return Err(N2WError::Custom {
898 module: "decimal",
899 class: "InvalidOperation",
900 msg: "[<class 'decimal.DivisionImpossible'>]".to_string(),
901 });
902 }
903 // `scaled >= 0` or not, with_scale(0) truncates toward zero — the same
904 // direction Decimal's `%` uses, so the != 0 test agrees on both signs.
905 let has_fractional_cents = &scaled - scaled.with_scale(0) != BigDecimal::zero();
906
907 let is_integer_input = matches!(val, CurrencyValue::Int(_));
908
909 // parse_currency_parts(val, is_int_with_cents=False): keep_precision
910 // and divisor stay at their defaults (False, 100).
911 let (left, right, is_negative) = parse_currency_parts(val, false, false, 100);
912 // keep_precision=False means `right` came through `with_scale(0)`, so
913 // its scale is 0 and this is the integer Python's `int(...)` produced.
914 let right = right.as_bigint_and_exponent().0;
915
916 let forms = self.currency_forms.get(currency).ok_or_else(|| {
917 N2WError::NotImplemented(format!(
918 "Currency code \"{}\" not implemented for \"{}\"",
919 currency,
920 self.lang_name()
921 ))
922 })?;
923 let cr1 = &forms.unit;
924 let cr2 = &forms.subunit;
925
926 // negword is "menus "; Python emits "%s " % self.negword.strip().
927 let minus_str = if is_negative {
928 format!("{} ", self.negword().trim())
929 } else {
930 String::new()
931 };
932 let money_str = self.money_verbose(&left, currency)?;
933
934 if is_integer_input {
935 let curr_name = self.pluralize(&left, cr1)?;
936 return Ok(format!("{}{} {}", minus_str, curr_name, money_str));
937 }
938
939 let cents_str = if has_fractional_cents && right.is_positive() {
940 // fractional_cents = right / 100.0 -> self.to_cardinal_float(...).
941 // `right` is a whole cent count in 0..=99, so right/100 is exact as
942 // a 2-place decimal and `cardinal_from_decimal` — which casts to
943 // f64 and derives the precision from its repr, exactly as Python's
944 // float path does — recovers the same double.
945 self.cardinal_from_decimal(&BigDecimal::new(right.clone(), 2))?
946 } else if cents {
947 self.cents_verbose(&right, currency)?
948 } else {
949 self.cents_terse(&right, currency)?
950 };
951
952 if right.is_zero() {
953 Ok(format!(
954 "{}{} {}",
955 minus_str,
956 self.pluralize(&left, cr1)?,
957 money_str
958 ))
959 } else if has_fractional_cents {
960 Ok(format!(
961 "{}{} {} {} {}",
962 minus_str,
963 self.pluralize(&left, cr1)?,
964 money_str,
965 cents_str,
966 // Hardcoded singular — Python passes a literal 1.
967 self.pluralize(&BigInt::one(), cr2)?
968 ))
969 } else {
970 Ok(format!(
971 "{}{} {} {} {}",
972 minus_str,
973 self.pluralize(&left, cr1)?,
974 money_str,
975 self.pluralize(&right, cr2)?,
976 cents_str
977 ))
978 }
979 }
980}