Skip to main content

num2words2_core/
lang_te.rs

1//! Port of `lang_TE.py` (Telugu).
2//!
3//! Registry: `CONVERTER_CLASSES["te"] -> lang_TE.Num2Word_TE` (verified in
4//! `num2words2/__init__.py:320`).
5//!
6//! Shape: **engine**. `Num2Word_TE` subclasses `Num2Word_EUR` and defines
7//! `low_numwords`/`mid_numwords`/`high_numwords` + `merge`, so
8//! `Num2Word_Base.to_cardinal` drives `splitnum`/`clean`/`merge`. `cards`,
9//! `maxval` and `merge` are all live here.
10//!
11//! # Inheritance chain
12//!
13//! `Num2Word_TE` -> `Num2Word_EUR` -> `Num2Word_Base`. TE overrides both
14//! `setup` and `set_high_numwords` **without calling `super()`**, so EUR
15//! contributes nothing that is observable in the four *numeric* modes:
16//!   * EUR's `setup` (the Latin `cent`/`illion` stem generator) is fully
17//!     shadowed — `high_numwords` is TE's `[(7, ..), (5, ..), (3, ..)]`, not
18//!     EUR's list of stems.
19//!   * EUR's `set_high_numwords` (long-scale illiard/illion pairing over
20//!     `zip(high, range(cap, 3, -6))`) is fully shadowed — TE ignores the
21//!     `high` argument entirely and reads `self.high_numwords` directly,
22//!     mapping `10**n -> word`.
23//!   * EUR's `GIGA_SUFFIX`/`MEGA_SUFFIX`/`gen_high_numwords` are never
24//!     reached.
25//!
26//! EUR *does* contribute the entire currency surface — `pluralize`,
27//! `CURRENCY_FORMS` and `CURRENCY_ADJECTIVES` all resolve to it, and the forms
28//! dict is not the one `lang_EUR.py` appears to define. See "Currency" below.
29//!
30//! Inherited from `Num2Word_Base` and left at the trait defaults:
31//!   * `negword` = `"(-) "` — TE's `setup` never assigns `negword`, so the
32//!     base default survives and negatives render as `"(-) ఏడు"` (not a
33//!     Telugu word). Confirmed against the corpus.
34//!   * `to_year(value, **kwargs) -> self.to_cardinal(value)` — TE and EUR both
35//!     leave it alone, so years are plain cardinals with no era handling and
36//!     **no** rejection of negatives: `to_year(-500)` == `"(-) అయిదు వంద"`.
37//!   * `title`/`is_title`/`exclude_title` — never enabled for TE.
38//!
39//! # Numbering system
40//!
41//! Indian (vedic) grouping, not the Western short/long scale. The card table
42//! is `10**7 -> కోట్ల` (crore), `10**5 -> లక్ష` (lakh), `10**3 -> వేయి`
43//! (thousand), `100 -> వంద`, plus every value `0..=99` spelled out
44//! individually. There is deliberately **no card for 10**6**, so a million is
45//! "పది లక్ష" (ten lakh) and a billion is "ఒకటి వంద కోట్ల" (one hundred
46//! crore).
47//!
48//! `MAXVAL = 1000 * highest card = 1000 * 10**7 = 10**10`, so `to_cardinal`
49//! raises `OverflowError` from `10**10` upward. This is a genuinely low
50//! ceiling compared to most languages — values are still `BigInt` (the
51//! overflow check must compare against `10**10` without truncating a huge
52//! input), but nothing below the ceiling exceeds 10 digits.
53//!
54//! # Faithfully reproduced Python bugs
55//!
56//! This is a port, not a rewrite. All of the following are wrong-looking and
57//! all are exactly what Python emits, verified against the interpreter and
58//! the frozen corpus:
59//!
60//! 1. **The `modifiers` table is dead code.** `merge` and `to_ordinal` both
61//!    test `text[-1] in self.modifiers`. `text[-1]` is a *single character*,
62//!    but every one of the 17 `modifiers` entries is 2-4 characters long —
63//!    each intended combining vowel sign (U+0C3E..U+0C4D, U+0C01..U+0C03) is
64//!    padded with stray ASCII spaces, e.g. `" ి "` is `U+0020 U+0C3F U+0020`
65//!    and `" ొ"` is `U+0020 U+0C4A`. A 1-char string can never equal a 2-char
66//!    string, so **both tests are always False**. Consequences:
67//!      * `merge`'s `lnum >= 100 > rnum` branch always takes the `else` arm
68//!        and appends the plural infix `ల`; the `ltext[:-1]` arm that would
69//!        strip a trailing vowel sign is unreachable.
70//!      * `to_ordinal` never strips anything and is always
71//!        `to_cardinal(value) + "వ"`.
72//!    [`last_char_is_modifier`] performs the real comparison rather than
73//!    hard-coding `false`, so the deadness is emergent from the ported table
74//!    exactly as in Python. Fixing the padding would change output.
75//!
76//! 2. **`cards[50]` has a trailing space**: `"యాభై "` (`lang_TE.py:64`),
77//!    while every other round ten is unpadded. This leaks into output:
78//!    `to_cardinal(50)` == `"యాభై "`, `to_year(1050)` ==
79//!    `"ఒకటి వేయిల యాభై "`, and — because the modifiers test above never
80//!    fires and the space is therefore not stripped — `to_ordinal(50)` ==
81//!    `"యాభై వ"` with a space before the `వ`, unlike `"నలభైవ"` (40).
82//!
83//! 3. **`cards[35]` is `"ముప్పై ఐదు"`**, using `ఐదు` for "five" where every
84//!    other compound in the table (5, 25, 45, 55, ...) uses `అయిదు`. A
85//!    one-off inconsistency in the source table, kept verbatim.
86//!
87//! 4. **`merge`'s `100 > lnum > rnum` branch (the `"%s-%s"` hyphen join) is
88//!    unreachable.** Because `cards` enumerates every value `0..=99`, any
89//!    sub-100 value is a direct card hit that `splitnum` resolves with
90//!    `div == 1` (taking `merge`'s first branch), and any `merge` carrying a
91//!    remainder has already accumulated `lnum >= elem >= 100`. No TE output
92//!    ever contains a hyphen. Ported anyway for structural fidelity.
93//!
94//! 5. **`to_cardinal(100)` == `"ఒకటి వంద"`** ("one hundred", via the
95//!    `rnum > lnum` multiply branch) whereas `to_cardinal(101)` ==
96//!    `"ఒకటి వందల ఒకటి"` — the `ల` infix appears only when a remainder
97//!    follows. Likewise `1000` -> `"ఒకటి వేయి"` but `1001` ->
98//!    `"ఒకటి వేయిల ఒకటి"`, and `1100` -> `"ఒకటి వేయి ఒకటి వంద"` (no infix,
99//!    since `rnum == 100` fails the strict `100 > rnum`).
100//!
101//! # Error variants
102//!
103//! Only two are reachable in scope, both deliberate raises rather than
104//! crashes:
105//!   * `OverflowError` -> [`N2WError::Overflow`], from
106//!     `Num2Word_Base.to_cardinal` at `value >= MAXVAL` (`10**10`).
107//!   * `TypeError` -> [`N2WError::Type`], from `verify_ordinal` on negative
108//!     input to `to_ordinal`/`to_ordinal_num`. `to_cardinal` and `to_year`
109//!     accept negatives.
110//!
111//! `to_ordinal_num` calls `verify_ordinal` and then `to_ordinal`, which calls
112//! `verify_ordinal` again — harmless, but it means a negative fails on the
113//! first check and an overflowing value fails inside the nested
114//! `to_cardinal`, so `to_ordinal_num(-1)` is a `TypeError` while
115//! `to_ordinal_num(10**10)` is an `OverflowError`. Both match the corpus.
116//!
117//! # Cross-call mutable state
118//!
119//! None. `Num2Word_TE` defines no `str_to_number` and stashes no flags; the
120//! only instance state is the immutable `cards`/`modifiers` tables built in
121//! `setup`. Nothing for the Python dispatcher to skip.
122//!
123//! # Currency
124//!
125//! TE defines **no** currency members at all — no `CURRENCY_FORMS`, no
126//! `CURRENCY_ADJECTIVES`, no `CURRENCY_PRECISION`, no `pluralize`, no
127//! `to_currency`/`to_cheque`, no `_money_verbose`/`_cents_verbose`. Everything
128//! is inherited, and *what* is inherited is not what `lang_EUR.py` reads like.
129//! See [`CURRENCY_FORMS`] — the table is assembled at import time by a
130//! different module.
131//!
132//! Resolution through the MRO (`Num2Word_TE` -> `Num2Word_EUR` ->
133//! `Num2Word_Base`):
134//!   * `CURRENCY_FORMS` -> `Num2Word_EUR.CURRENCY_FORMS`, **as mutated by
135//!     `Num2Word_EN.__init__`** (see below).
136//!   * `CURRENCY_ADJECTIVES` -> `Num2Word_EUR.CURRENCY_ADJECTIVES`, unmutated
137//!     — nothing in the library writes into it, only reads.
138//!   * `CURRENCY_PRECISION` -> `Num2Word_Base.CURRENCY_PRECISION`, which is
139//!     `{}` — see [`Lang::currency_precision`] note below. **Every** currency
140//!     is 2-decimal for TE, including KWD/BHD and JPY.
141//!   * `pluralize` -> `Num2Word_EUR.pluralize` (`forms[0 if n == 1 else 1]`).
142//!   * `to_currency` / `to_cheque` / `_money_verbose` / `_cents_verbose` /
143//!     `_cents_terse` -> `Num2Word_Base`, i.e. the trait defaults.
144
145use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
146use crate::currency::CurrencyForms;
147use crate::floatpath::FloatValue;
148use num_bigint::BigInt;
149use num_traits::{One, Signed, Zero};
150use std::collections::HashMap;
151
152/// The plural/oblique infix `merge` appends before a remainder (`ల`, U+0C32).
153const LA: &str = "\u{c32}";
154
155/// The ordinal suffix `to_ordinal` appends (`వ`, U+0C35).
156const VA: &str = "\u{c35}";
157
158/// `self.modifiers` from `lang_TE.py`, verbatim.
159///
160/// Written with `\u{}` escapes because every entry mixes a lone Telugu
161/// combining mark with ASCII spaces, which renders as mojibake in an editor
162/// and would be trivially corrupted by hand-transcription. The trailing
163/// comment on each line gives the exact codepoint sequence.
164///
165/// **Every entry is 2-4 chars**, which is precisely why the `text[-1] in
166/// modifiers` tests in `merge`/`to_ordinal` can never match — see the module
167/// docs, bug 1. Do not "clean up" the spaces.
168const MODIFIERS: [&str; 17] = [
169    "\u{20}\u{c4d}\u{20}\u{20}", // U+0020 U+0C4D U+0020 U+0020
170    "\u{c3e}\u{20}",             // U+0C3E U+0020
171    "\u{20}\u{c3f}\u{20}",       // U+0020 U+0C3F U+0020
172    "\u{20}\u{c40}\u{20}",       // U+0020 U+0C40 U+0020
173    "\u{20}\u{c41}\u{20}",       // U+0020 U+0C41 U+0020
174    "\u{20}\u{c42}\u{20}",       // U+0020 U+0C42 U+0020
175    "\u{20}\u{c43}\u{20}",       // U+0020 U+0C43 U+0020
176    "\u{20}\u{c44}\u{20}\u{20}", // U+0020 U+0C44 U+0020 U+0020
177    "\u{20}\u{c46}\u{20}",       // U+0020 U+0C46 U+0020
178    "\u{20}\u{c47}\u{20}",       // U+0020 U+0C47 U+0020
179    "\u{20}\u{c48}\u{20}",       // U+0020 U+0C48 U+0020
180    "\u{20}\u{c4a}",             // U+0020 U+0C4A
181    "\u{20}\u{c4b}\u{20}",       // U+0020 U+0C4B U+0020
182    "\u{20}\u{c4c}\u{20}",       // U+0020 U+0C4C U+0020
183    "\u{20}\u{c01}\u{20}",       // U+0020 U+0C01 U+0020
184    "\u{20}\u{c02}\u{20}",       // U+0020 U+0C02 U+0020
185    "\u{20}\u{c03}\u{20}",       // U+0020 U+0C03 U+0020
186];
187
188/// `self.high_numwords`: `(exponent, word)`, mapped by TE's
189/// `set_high_numwords` to `cards[10**n] = word`.
190const HIGH: [(u32, &str); 3] = [(7, "కోట్ల"), (5, "లక్ష"), (3, "వేయి")];
191
192/// The effective `CURRENCY_FORMS` for `Num2Word_TE`: `(code, unit, subunit)`.
193///
194/// # This table is NOT what `lang_EUR.py` says. Read this before "fixing" it.
195///
196/// `Num2Word_TE` inherits `CURRENCY_FORMS` from `Num2Word_EUR`, and
197/// `lang_EUR.py`'s class body lists 22 codes with e.g.
198/// `"EUR": (("euro", "euro"), ...)` and `"GBP": (("pound sterling",
199/// "pounds sterling"), ...)`. **That literal is not what TE sees at runtime.**
200/// TE sees 39 codes, `EUR` pluralises to `"euros"`, and `GBP` is plain
201/// `"pound"/"pounds"`.
202///
203/// The reason is a shared-mutable-class-attribute side effect in
204/// `lang_EN.py`. `Num2Word_EN(Num2Word_EUR)` declares no `CURRENCY_FORMS` of
205/// its own, so in its `__init__` the statement
206///
207/// ```python
208/// self.CURRENCY_FORMS["EUR"] = (("euro", "euros"), ("cent", "cents"))
209/// ```
210///
211/// is an **item assignment**, not a rebind: attribute lookup walks the MRO to
212/// `Num2Word_EUR.CURRENCY_FORMS` and mutates *that dict in place*. There is one
213/// dict object for the whole `Num2Word_EUR` subtree, so every one of EN's ~27
214/// writes is visible to every EUR subclass — `hu`, `kn`, `sv` and `te`
215/// included. `num2words2/__init__.py` instantiates every converter eagerly in
216/// the `CONVERTER_CLASSES` literal, so all of this has already happened before
217/// any caller can observe it, and the merged result is deterministic.
218/// `Num2Word_EN_IN.__init__` writes `INR` too, with the identical value.
219///
220/// Verified at runtime rather than reasoned about:
221/// `CONVERTER_CLASSES["te"].CURRENCY_FORMS is lang_EUR.Num2Word_EUR
222/// .CURRENCY_FORMS` is `True`, and this table is a mechanical dump of that
223/// dict after a full `import num2words2`. The whole thing was code-generated
224/// from the live object — `fillér`, `króna`/`krónur`, `øre`, `öre` are easy to
225/// corrupt by hand. Regenerate rather than retype.
226///
227/// Consequences worth stating out loud, all corpus-confirmed:
228///   * `EUR` is `("euro", "euros")` — EN's value, not EUR's `("euro", "euro")`.
229///   * `GBP` is `("pound", "pounds")`, not `("pound sterling", ...)`.
230///   * `SAR` is `("riyal", "riyals")`, not EUR's `("saudi riyal", ...)`.
231///   * 17 codes exist *only* because EN added them: AED, BRL, CHF, HKD, IQD,
232///     JOD, KWD, LYD, NGN, NZD, OMR, QAR, SGD, TND, ZAR (plus BHD, CNY).
233///   * `PLN` and `RON` keep **three** unit forms. `pluralize` only ever indexes
234///     0 or 1, so the third is unreachable, but the arity is preserved because
235///     dropping it would be a silent behaviour change if `pluralize` ever
236///     grew a third branch.
237const CURRENCY_FORMS: [(&str, &[&str], &[&str]); 39] = [
238    ("AED", &["dirham", "dirhams"], &["fils", "fils"]),
239    ("AUD", &["dollar", "dollars"], &["cent", "cents"]),
240    ("BHD", &["dinar", "dinars"], &["fils", "fils"]),
241    ("BRL", &["real", "reais"], &["cent", "cents"]),
242    ("BYN", &["rouble", "roubles"], &["kopek", "kopeks"]),
243    ("CAD", &["dollar", "dollars"], &["cent", "cents"]),
244    ("CHF", &["franc", "francs"], &["rappen", "rappen"]),
245    ("CNY", &["yuan", "yuan"], &["fen", "fen"]),
246    ("EEK", &["kroon", "kroons"], &["sent", "senti"]),
247    ("EUR", &["euro", "euros"], &["cent", "cents"]),
248    ("GBP", &["pound", "pounds"], &["penny", "pence"]),
249    ("HKD", &["dollar", "dollars"], &["cent", "cents"]),
250    ("HUF", &["forint", "forint"], &["fillér", "fillér"]),
251    ("INR", &["rupee", "rupees"], &["paisa", "paise"]),
252    ("IQD", &["dinar", "dinars"], &["fils", "fils"]),
253    ("ISK", &["króna", "krónur"], &["aur", "aurar"]),
254    ("JOD", &["dinar", "dinars"], &["fils", "fils"]),
255    ("JPY", &["yen", "yen"], &["sen", "sen"]),
256    ("KRW", &["won", "won"], &["jeon", "jeon"]),
257    ("KWD", &["dinar", "dinars"], &["fils", "fils"]),
258    ("LTL", &["litas", "litas"], &["cent", "cents"]),
259    ("LVL", &["lat", "lats"], &["santim", "santims"]),
260    ("LYD", &["dinar", "dinars"], &["dirham", "dirhams"]),
261    ("MXN", &["peso", "pesos"], &["cent", "cents"]),
262    ("NGN", &["naira", "naira"], &["kobo", "kobo"]),
263    ("NOK", &["krone", "kroner"], &["øre", "øre"]),
264    ("NZD", &["dollar", "dollars"], &["cent", "cents"]),
265    ("OMR", &["rial", "rials"], &["baisa", "baisa"]),
266    ("PLN", &["zloty", "zlotys", "zlotu"], &["grosz", "groszy"]),
267    ("QAR", &["riyal", "riyals"], &["dirham", "dirhams"]),
268    ("RON", &["leu", "lei", "de lei"], &["ban", "bani", "de bani"]),
269    ("RUB", &["rouble", "roubles"], &["kopek", "kopeks"]),
270    ("SAR", &["riyal", "riyals"], &["halalah", "halalas"]),
271    ("SEK", &["krona", "kronor"], &["öre", "öre"]),
272    ("SGD", &["dollar", "dollars"], &["cent", "cents"]),
273    ("TND", &["dinar", "dinars"], &["millime", "millimes"]),
274    ("USD", &["dollar", "dollars"], &["cent", "cents"]),
275    ("UZS", &["sum", "sums"], &["tiyin", "tiyins"]),
276    ("ZAR", &["rand", "rand"], &["cent", "cents"]),
277];
278
279/// `self.low_numwords`, in Python source order.
280///
281/// `set_low_numwords` zips this against `range(99, -1, -1)`, so index 0 is
282/// the word for 99 and index 99 is the word for 0. Two entries are odd and
283/// intentional: `"యాభై "` (50) carries a trailing space, and
284/// `"ముప్పై ఐదు"` (35) uses `ఐదు` where its neighbours use `అయిదు`. See
285/// module docs, bugs 2 and 3.
286///
287/// # DO NOT retype or reformat this table — it is not NFC
288///
289/// `lang_TE.py` stores the vowel sign ై **decomposed**, as
290/// `U+0C46 U+0C56` (VOWEL SIGN E + AI LENGTH MARK), not as the canonically
291/// equivalent precomposed `U+0C48`. 78 of these 100 entries contain it (every
292/// word built on ఇరవై / ముప్పై / నలభై / యాభై / అరవై / డెబ్బై / ఎనభై / తొంభై).
293/// The two forms are visually identical and compare equal under NFC, but they
294/// are **different bytes**, and the corpus fixes the decomposed bytes that
295/// Python emits.
296///
297/// Any round-trip through a normalizing editor, formatter, or hand-copy will
298/// silently rewrite these to NFC and break `to_cardinal` for ~80% of the
299/// range while looking completely correct in a diff. This table was spliced
300/// programmatically out of the Python module for exactly that reason; if it
301/// ever needs to change, regenerate it rather than typing it, and verify with
302/// a byte comparison rather than by eye.
303const LOW: [&str; 100] = [
304    "తొంభై తొమ్మిది",
305    "తొంభై ఎనిమిది",
306    "తొంభై ఏడు",
307    "తొంభై ఆరు",
308    "తొంభై అయిదు",
309    "తొంభై నాలుగు",
310    "తొంభై మూడు",
311    "తొంభై రెండు",
312    "తొంభై ఒకటి",
313    "తొంభై",
314    "ఎనభై తొమ్మిది",
315    "ఎనభై ఎనిమిది",
316    "ఎనభై ఏడు",
317    "ఎనభై ఆరు",
318    "ఎనభై అయిదు",
319    "ఎనభై నాలుగు",
320    "ఎనభై మూడు",
321    "ఎనభై రెండు",
322    "ఎనభై ఒకటి",
323    "ఎనభై",
324    "డెబ్బై తొమ్మిది",
325    "డెబ్బై ఎనిమిది",
326    "డెబ్బై ఏడు",
327    "డెబ్బై ఆరు",
328    "డెబ్బై అయిదు",
329    "డెబ్బై నాలుగు",
330    "డెబ్బై మూడు",
331    "డెబ్బై రెండు",
332    "డెబ్బై ఒకటి",
333    "డెబ్బై",
334    "అరవై తొమ్మిది",
335    "అరవై ఎనిమిది",
336    "అరవై ఏడు",
337    "అరవై ఆరు",
338    "అరవై అయిదు",
339    "అరవై నాలుగు",
340    "అరవై మూడు",
341    "అరవై రెండు",
342    "అరవై ఒకటి",
343    "అరవై",
344    "యాభై తొమ్మిది",
345    "యాభై ఎనిమిది",
346    "యాభై ఏడు",
347    "యాభై ఆరు",
348    "యాభై అయిదు",
349    "యాభై నాలుగు",
350    "యాభై మూడు",
351    "యాభై రెండు",
352    "యాభై ఒకటి",
353    "యాభై ",  // 50: trailing space, verbatim from lang_TE.py:64 - see bug 2
354    "నలభై తొమ్మిది",
355    "నలభై ఎనిమిది",
356    "నలభై ఏడు",
357    "నలభై ఆరు",
358    "నలభై అయిదు",
359    "నలభై నాలుగు",
360    "నలభై మూడు",
361    "నలభై రెండు",
362    "నలభై ఒకటి",
363    "నలభై",
364    "ముప్పై తొమ్మిది",
365    "ముప్పై ఎనిమిది",
366    "ముప్పై ఏడు",
367    "ముప్పై ఆరు",
368    "ముప్పై ఐదు",  // 35: `aidu` not `ayidu` - verbatim, see bug 3
369    "ముప్పై నాలుగు",
370    "ముప్పై మూడు",
371    "ముప్పై రెండు",
372    "ముప్పై ఒకటి",
373    "ముప్పై",
374    "ఇరవై తొమ్మిది",
375    "ఇరవై ఎనిమిది",
376    "ఇరవై ఏడు",
377    "ఇరవై ఆరు",
378    "ఇరవై అయిదు",
379    "ఇరవై నాలుగు",
380    "ఇరవై మూడు",
381    "ఇరవై రెండు",
382    "ఇరవై ఒకటి",
383    "ఇరవై",
384    "పందొమ్మిది",
385    "పధ్ధెనిమిది",
386    "పదిహేడు",
387    "పదహారు",
388    "పదునయిదు",
389    "పధ్నాలుగు",
390    "పదమూడు",
391    "పన్నెండు",
392    "పదకొండు",
393    "పది",
394    "తొమ్మిది",
395    "ఎనిమిది",
396    "ఏడు",
397    "ఆరు",
398    "అయిదు",
399    "నాలుగు",
400    "మూడు",
401    "రెండు",
402    "ఒకటి",
403    "సున్న",
404];
405
406/// Python's `text[-1] in self.modifiers`.
407///
408/// Takes the last **character** (not byte — the words are Telugu) and tests
409/// it for membership in [`MODIFIERS`] by string equality, exactly as Python's
410/// `in` does against a list. Always returns `false` in practice because every
411/// modifier is multi-character; see module docs, bug 1.
412///
413/// Python would raise `IndexError` on `""[-1]`, but the callers can never
414/// pass an empty string: `merge`'s `ltext` is either a card word (all
415/// non-empty) or a previous `merge` result (all non-empty), and
416/// `to_ordinal`'s input is a completed `to_cardinal`. `false` is therefore an
417/// unreachable-branch placeholder, not a behavioural choice.
418fn last_char_is_modifier(text: &str) -> bool {
419    match text.chars().next_back() {
420        Some(c) => {
421            let mut buf = [0u8; 4];
422            let last: &str = c.encode_utf8(&mut buf);
423            MODIFIERS.iter().any(|m| *m == last)
424        }
425        None => false,
426    }
427}
428
429pub struct LangTe {
430    cards: Cards,
431    maxval: BigInt,
432    /// [`CURRENCY_FORMS`] materialised once, in [`LangTe::new`].
433    ///
434    /// Python resolves `self.CURRENCY_FORMS[code]` to a single dict shared
435    /// across the whole `Num2Word_EUR` subtree and built once at import. The
436    /// equivalent here is one owned map per converter instance, built once in
437    /// `new()`. Rebuilding it per `to_currency` call would allocate 39
438    /// `CurrencyForms` (each with its own `Vec<String>`) to answer a single
439    /// lookup, which is how an earlier iteration of this port ended up
440    /// measurably slower than the Python it replaces.
441    currency_forms: HashMap<&'static str, CurrencyForms>,
442}
443
444impl Default for LangTe {
445    fn default() -> Self {
446        Self::new()
447    }
448}
449
450impl LangTe {
451    pub fn new() -> Self {
452        let mut cards = Cards::new();
453
454        // TE's set_high_numwords ignores its `high` argument and reads
455        // self.high_numwords directly: cards[10**n] = word.
456        for (n, word) in HIGH.iter() {
457            cards.insert(BigInt::from(10u8).pow(*n), *word);
458        }
459
460        // self.mid_numwords = [(100, "వంద")]
461        set_mid_numwords(&mut cards, &[(100, "వంద")]);
462
463        set_low_numwords(&mut cards, &LOW);
464
465        // MAXVAL = 1000 * list(self.cards.keys())[0] = 1000 * 10**7 = 10**10
466        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
467
468        // Built once, here — never per call. See the field docs.
469        let currency_forms = CURRENCY_FORMS
470            .iter()
471            .map(|(code, unit, subunit)| (*code, CurrencyForms::new(unit, subunit)))
472            .collect();
473
474        LangTe {
475            cards,
476            maxval,
477            currency_forms,
478        }
479    }
480
481    /// Python's `Num2Word_Base.verify_ordinal`.
482    ///
483    /// The float check (`value == int(value)`) is vacuous for `BigInt`, so
484    /// only the negative check can fire.
485    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
486        if value.sign() == num_bigint::Sign::Minus {
487            return Err(N2WError::Type(format!(
488                "Cannot treat negative num {} as ordinal.",
489                value
490            )));
491        }
492        Ok(())
493    }
494}
495
496impl Lang for LangTe {
497    /// This language's own `to_currency(currency=...)` default,
498    /// read from the live Python signature. Only 44 of 156 use EUR.
499    fn default_currency(&self) -> &str {
500        "EUR"
501    }
502
503    /// This language's own `to_currency(separator=...)` default,
504    /// read from the live Python signature. Base's is ",", but only
505    /// 36 of 149 languages actually use it — most default to " " or a
506    /// conjunction, so inheriting Base's comma silently corrupts them.
507    fn default_separator(&self) -> &str {
508        ","
509    }
510
511    fn cards(&self) -> &Cards {
512        &self.cards
513    }
514
515    fn maxval(&self) -> &BigInt {
516        &self.maxval
517    }
518
519    /// TE's `setup` never touches `negword`, so `Num2Word_Base`'s default
520    /// survives. Stated explicitly because "(-) " looks like a placeholder
521    /// someone forgot to translate — it is what Python emits.
522    fn negword(&self) -> &str {
523        "(-) "
524    }
525
526    /// `self.pointword` from TE's `setup`, trailing space included. Only the
527    /// float path reads it, so it is out of scope; recorded for completeness.
528    fn pointword(&self) -> &str {
529        "బిందువు "
530    }
531
532    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
533        let (ltext, lnum) = l;
534        let (rtext, rnum) = r;
535        let hundred = BigInt::from(100);
536
537        if lnum.is_one() && rnum < &hundred {
538            // if lnum == 1 and rnum < 100: return (rtext, rnum)
539            (rtext.to_string(), rnum.clone())
540        } else if &hundred > lnum && lnum > rnum {
541            // elif 100 > lnum > rnum: "%s-%s"
542            // Unreachable for TE (module docs, bug 4) — ported for fidelity.
543            (format!("{}-{}", ltext, rtext), lnum + rnum)
544        } else if lnum >= &hundred && &hundred > rnum {
545            // elif lnum >= 100 > rnum
546            if last_char_is_modifier(ltext) {
547                // Dead arm (bug 1). Python: "%s %s" % (ltext[:-1], rtext) —
548                // drops the final *character*.
549                let mut trimmed = ltext.to_string();
550                trimmed.pop();
551                (format!("{} {}", trimmed, rtext), lnum + rnum)
552            } else {
553                // "%s %s" % (ltext + "ల", rtext)
554                (format!("{}{} {}", ltext, LA, rtext), lnum + rnum)
555            }
556        } else if rnum > lnum {
557            // elif rnum > lnum: "%s %s", lnum * rnum
558            (format!("{} {}", ltext, rtext), lnum * rnum)
559        } else {
560            // return "%s %s", lnum + rnum
561            (format!("{} {}", ltext, rtext), lnum + rnum)
562        }
563    }
564
565    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
566        self.verify_ordinal(value)?;
567        let mut outwords = self.to_cardinal(value)?;
568        if last_char_is_modifier(&outwords) {
569            // Dead arm (bug 1): Python's outwords = outwords[:-1].
570            outwords.pop();
571        }
572        outwords.push_str(VA);
573        Ok(outwords)
574    }
575
576    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
577        self.verify_ordinal(value)?;
578        let ord = self.to_ordinal(value)?;
579        // Python: "%s%s" % (value, self.to_ordinal(value)[-1:]) — a *slice*,
580        // so an empty ordinal would yield "" rather than raising IndexError.
581        let suffix: String = ord.chars().next_back().map(String::from).unwrap_or_default();
582        Ok(format!("{}{}", value, suffix))
583    }
584
585    /// `to_ordinal(float/Decimal)` — `verify_ordinal` gates the float domain:
586    /// a fractional value raises TypeError ("Cannot treat float %s as
587    /// ordinal.") before the negative check ("Cannot treat negative num %s
588    /// as ordinal."); -0.0 passes both (`abs(-0.0) == -0.0`). A surviving
589    /// whole value takes the integer ordinal path — base `to_cardinal`
590    /// routes a whole float through `int(value)` — so `5.00` is "అయిదువ"
591    /// and `1e+12` still overflows inside `to_cardinal` (MAXVAL is 10**10).
592    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
593        let i = verify_ordinal_float(value, &ordinal_float_repr(value))?;
594        self.to_ordinal(&i)
595    }
596
597    /// `to_ordinal_num(float/Decimal)` — the same gate, then
598    /// `"%s%s" % (value, self.to_ordinal(value)[-1:])`: the repr plus the
599    /// *last character* of the real ordinal ("5.0వ"). `to_ordinal` runs for
600    /// real, so a whole value past MAXVAL ("1E+20") raises OverflowError
601    /// here rather than echoing.
602    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
603        let i = verify_ordinal_float(value, repr_str)?;
604        let ord = self.to_ordinal(&i)?;
605        let suffix: String = ord.chars().next_back().map(String::from).unwrap_or_default();
606        Ok(format!("{}{}", repr_str, suffix))
607    }
608
609    // to_year: Num2Word_Base's default (`return self.to_cardinal(value)`) is
610    // inherited unchanged through EUR and TE, and the trait default already
611    // delegates through `&self`. Negatives are NOT rejected here — only
612    // to_ordinal* call verify_ordinal.
613
614    // ---- currency ----------------------------------------------------
615
616    /// `self.__class__.__name__`, interpolated into the NotImplementedError
617    /// that `to_currency`/`to_cheque` raise for an unknown code. TE is the
618    /// concrete class, so it is `Num2Word_TE` and not any of its bases.
619    fn lang_name(&self) -> &str {
620        "Num2Word_TE"
621    }
622
623    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
624        self.currency_forms.get(code)
625    }
626
627    /// `Num2Word_EUR.CURRENCY_ADJECTIVES`, verbatim and complete.
628    ///
629    /// Unlike `CURRENCY_FORMS`, this dict is never mutated — every reference in
630    /// the library reads it (`self.CURRENCY_ADJECTIVES[currency]`), so what TE
631    /// inherits is exactly `lang_EUR.py`'s literal. Confirmed by dumping the
632    /// live object.
633    ///
634    /// Only reachable via `adjective=True`, which no corpus row exercises, so
635    /// this hook is ported from the source rather than pinned by the oracle.
636    /// Note `íslenskar` (ISK) is lowercase Icelandic among otherwise
637    /// capitalised English demonyms — that is what `lang_EUR.py` says.
638    fn currency_adjective(&self, code: &str) -> Option<&str> {
639        match code {
640            "AUD" => Some("Australian"),
641            "BYN" => Some("Belarusian"),
642            "CAD" => Some("Canadian"),
643            "EEK" => Some("Estonian"),
644            "HUF" => Some("Hungarian"),
645            "INR" => Some("Indian"),
646            "ISK" => Some("íslenskar"),
647            "JPY" => Some("Japanese"),
648            "KRW" => Some("Korean"),
649            "MXN" => Some("Mexican"),
650            "NOK" => Some("Norwegian"),
651            "RON" => Some("Romanian"),
652            "RUB" => Some("Russian"),
653            "SAR" => Some("Saudi"),
654            "USD" => Some("US"),
655            "UZS" => Some("Uzbekistan"),
656            _ => None,
657        }
658    }
659
660    // currency_precision: deliberately NOT overridden. `Num2Word_TE` and
661    // `Num2Word_EUR` both leave `CURRENCY_PRECISION` alone, so it resolves to
662    // `Num2Word_Base.CURRENCY_PRECISION`, which is `{}` — every code takes the
663    // `.get(code, 100)` default of 100. The trait default returns 100, which is
664    // already right.
665    //
666    // This one is worth being explicit about, because the *forms* table above
667    // does leak out of `lang_EN.py` and the precision table looks like it
668    // should leak the same way. It does not. `lang_EN.py` writes its forms with
669    // `self.CURRENCY_FORMS["KWD"] = ...` (item assignment -> mutates the shared
670    // EUR dict) but its precisions with
671    //
672    //     self.CURRENCY_PRECISION = {"BHD": 1000, "KWD": 1000, ...}
673    //
674    // which is a *rebind*: it creates an instance attribute on the EN instance
675    // and leaves `Num2Word_Base.CURRENCY_PRECISION` empty. Nothing anywhere in
676    // the library does `CURRENCY_PRECISION[...] = ...`, so no code path can
677    // ever populate it for TE.
678    //
679    // So, for TE, and confirmed against the corpus:
680    //   * KWD and BHD are 2-decimal, not 3. `to_currency(12.34, "KWD")` is
681    //     "పన్నెండు dinars, ముప్పై నాలుగు fils" (34 fils), not 340, and
682    //     `to_cheque(1234.56, "KWD")` prints "56/100", not "560/1000".
683    //   * JPY is 2-decimal, not 0. It keeps its historical `sen` subunit and
684    //     `to_currency(12.34, "JPY")` renders cents rather than rounding to a
685    //     whole yen — the `divisor == 1` branch in `default_to_currency` is
686    //     unreachable for TE.
687    // A language-shaped "fix" here would silently break 24 corpus rows.
688
689    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
690    ///
691    /// Not a Telugu rule — TE inherits EUR's English-style binary plural and
692    /// applies it to the (English) currency names in `CURRENCY_FORMS`. The
693    /// Telugu words in the output come from `to_cardinal`; the unit names never
694    /// get translated. That is what the corpus fixes:
695    /// `to_currency(2, "EUR", lang="te")` == `"రెండు euros"`.
696    ///
697    /// `n` here is always non-negative — `to_currency` passes `abs(val)` on the
698    /// int path and `parse_currency_parts` returns magnitudes on the float
699    /// path — so the `n == 1` test needs no sign handling.
700    ///
701    /// The `Index` error is Python's `IndexError` from `forms[1]` on a
702    /// one-element tuple. Unreachable with the table above (every entry has 2
703    /// or 3 forms), but spelled out rather than unwrapped so a future table
704    /// edit surfaces as the exception Python would raise instead of a panic.
705    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
706        let form = if n.is_one() { 0 } else { 1 };
707        forms
708            .get(form)
709            .cloned()
710            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
711    }
712
713    // _money_verbose / _cents_verbose / _cents_terse: TE overrides none of
714    // them, so `Num2Word_Base`'s versions apply — `to_cardinal(number)` for the
715    // first two and the zero-padded `"%0*d"` for the third. The trait defaults
716    // are exactly those. Left alone.
717    //
718    // to_currency / to_cheque: TE and EUR define neither, so
719    // `Num2Word_Base.to_currency` / `.to_cheque` run unmodified and the trait
720    // defaults (`currency::default_to_currency` / `default_to_cheque`) already
721    // are that code. Left alone.
722    //
723    // cardinal_from_decimal: left at the default, which raises. It backs the
724    // fractional-cents branch of `default_to_currency` (Python's
725    // `self.to_cardinal(float(right))`), which TE reaches only for input with
726    // more than 2 decimals, e.g. `to_currency(1.234, "EUR")`. Python renders
727    // that via `to_cardinal_float` using `pointword = "బిందువు "`; the float
728    // cardinal path is a later phase, so this Rust port raises where Python
729    // returns a string. No corpus row hits it — every currency value is 0, 1, 2
730    // decimals — but it is a real gap, flagged in `concerns`.
731}
732
733/// Python's `Num2Word_Base.verify_ordinal` over the float/Decimal domain:
734///
735/// ```python
736/// if not value == int(value):
737///     raise TypeError(self.errmsg_floatord % value)
738/// if not abs(value) == value:
739///     raise TypeError(self.errmsg_negord % value)
740/// ```
741///
742/// The float check runs first, so `-1.5` reports the *float* message; the
743/// negative check compares numerically, so `-0.0` (== `abs(-0.0)`) passes.
744/// `repr` is Python's `str(value)`, interpolated verbatim into the message.
745fn verify_ordinal_float(value: &FloatValue, repr: &str) -> Result<BigInt> {
746    match value.as_whole_int() {
747        None => Err(N2WError::Type(format!(
748            "Cannot treat float {} as ordinal.",
749            repr
750        ))),
751        Some(i) if i.is_negative() => Err(N2WError::Type(format!(
752            "Cannot treat negative num {} as ordinal.",
753            repr
754        ))),
755        Some(i) => Ok(i),
756    }
757}
758
759/// Best-effort `str(value)` for the TypeError messages raised by
760/// [`verify_ordinal_float`] when no repr was handed in (the `to_ordinal`
761/// entry). Exact for every reachable message: `str(Decimal)` is the spec
762/// transcription, a fractional float's shortest round-trip matches Rust's
763/// `Display` in the non-exponent range, and a whole float re-gains its
764/// Python ".0" tail. (A whole float >= 1e16 would diverge — Python uses
765/// exponent form — but such values pass verification and never reach a
766/// message.)
767fn ordinal_float_repr(value: &FloatValue) -> String {
768    match value {
769        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
770        FloatValue::Float { value, .. } => {
771            if value.is_finite() && value.fract() == 0.0 && value.abs() < 1e16 {
772                format!("{:.1}", value)
773            } else {
774                value.to_string()
775            }
776        }
777    }
778}