num2words2_core/lang_tg.rs
1//! Port of `lang_TG.py` (Tajik), via its `lang_EUR` → `Num2Word_Base` ancestry.
2//!
3//! Shape: **engine**. `Num2Word_TG` supplies `high_numwords`/`mid_numwords`/
4//! `low_numwords` + `merge`, so Python builds `self.cards` and `MAXVAL` in
5//! `Num2Word_Base.__init__` and folds via `splitnum`/`clean`. `to_cardinal` is
6//! overridden, but only to bolt a `value == 100` special case onto the base
7//! algorithm — the tree walk itself is still `base.py`'s, so `splitnum`/`clean`
8//! from `base.rs` are reused verbatim.
9//!
10//! # Inheritance notes (chased through the chain)
11//!
12//! * `TG.setup()` calls `super().setup()` (= `Num2Word_EUR.setup()`), which
13//! assigns the full Latin-prefix `high_numwords` list — and then TG
14//! **immediately overwrites it** with `gen_high_numwords([], [], lows)`.
15//! With `units` and `tens` both empty, the `[u + t for t in tens for u in
16//! units]` comprehension yields `[]`, the reverse and the 18 elision
17//! replacements are no-ops, and the result is just `lows` verbatim:
18//! `["квинт", "квадр", "тр", "м", "м"]`. So EUR's expensive name generation
19//! is dead code for Tajik and is not reproduced here.
20//! * `TG.set_high_numwords` overrides `EUR`'s (step -3 with a `n == 9` giga
21//! check, not EUR's -6 illiard/illion pairing). `cap = 3 * (len(high) + 1)`
22//! = 18, and `zip(high, range(18, 5, -3))` pairs the 5 stems with
23//! `[18, 15, 12, 9, 6]` exactly — neither sequence is truncated.
24//! * `MAXVAL = 1000 * list(self.cards.keys())[0]`. The first key inserted is
25//! `10**18`, which is also the largest, so `Cards::highest()` agrees with
26//! Python's insertion-order lookup. MAXVAL = `10**21`.
27//! * `is_title` stays `False` (base default), so `title()` is the identity.
28//! `exclude_title` is carried anyway for faithfulness.
29//! * `to_year` is **not** overridden anywhere in the chain, so it stays
30//! `Num2Word_Base.to_year(value) -> self.to_cardinal(value)`. The trait
31//! default does exactly that and picks up the `to_cardinal` override below,
32//! so it is deliberately left alone.
33//! * `verify_ordinal` is `Num2Word_Base`'s: negatives raise `TypeError`
34//! ("Cannot treat negative num %s as ordinal."). Integer input can never
35//! trip the float branch.
36//!
37//! # Quirks reproduced verbatim
38//!
39//! 1. `to_cardinal` special-cases `value == 100` → "сад" *after* stripping the
40//! sign, so `to_cardinal(-100)` == "минус сад". Without that early return
41//! `splitnum(100)`/`merge` would have produced "яксад" (as it still does
42//! inside larger numbers: 1100 → "як ҳазору яксад"). The bare "сад" form
43//! therefore only ever appears at top level.
44//! 2. `TG.to_cardinal` writes `out = self.negword` where `Num2Word_Base` writes
45//! `out = "%s " % self.negword.strip()`. Because `negword` is "минус " —
46//! already trailing-space — both spellings produce the identical "минус ".
47//! Kept as the raw `negword` to mirror the source.
48//! 3. `merge`'s "яксад" arm (`ltext == "яксад" and rtext not in
49//! self.low_numwords` → "сад %s") is what makes 100_000 render "сад ҳазор"
50//! rather than "яксад ҳазор". The `rtext not in low_numwords` guard is
51//! effectively dead: it is only consulted on the `rnum > lnum` arm, where
52//! `lnum == 100` forces `rnum > 100`, while every `low_numwords` entry maps
53//! to a value ≤ 20. It is preserved regardless.
54//! 4. `merge` hard-codes the joiners by *text*, not by value: "си" takes "ю "
55//! (30 → "сию як") and "панҷоҳ" is rewritten wholesale to "панҷову"
56//! (50 → "панҷову панҷ"), while every other ten takes "у " (20 → "бисту
57//! як"). String comparison, not arithmetic — ported as such.
58//! 5. `to_ordinal_num` slices `self.to_ordinal(value)[-2:]` — the last two
59//! *characters* of a Cyrillic string. Sliced via `chars()`, never bytes.
60//! 6. `to_currency(..., adjective=True)` prefixes USD/RUB with the *English*
61//! adjectives inherited from `lang_EUR` ("ду US доллар"). See
62//! `build_currency_adjectives`.
63//! 7. The `value == 100` cardinal special case reaches the currency surface
64//! through `_money_verbose`, so `to_currency(100, "EUR")` is "сад евро" —
65//! not the "яксад евро" that `splitnum`/`merge` alone would give.
66//!
67//! No cross-call mutable state: every method here is a pure function of its
68//! argument plus the immutable tables built in `new()`.
69
70use crate::base::{
71 clean, set_low_numwords, set_mid_numwords, splitnum, Cards, Lang, N2WError, Node, Result,
72};
73use crate::currency::CurrencyForms;
74use num_bigint::BigInt;
75use num_traits::{One, Signed, Zero};
76use std::collections::HashMap;
77
78/// `Num2Word_TG.GIGA_SUFFIX`.
79const GIGA_SUFFIX: &str = "иллиард";
80/// `Num2Word_TG.MEGA_SUFFIX`.
81const MEGA_SUFFIX: &str = "иллион";
82
83/// `self.low_numwords`, in source order (index 0 → 20, index 20 → 0).
84///
85/// Kept as a standalone table because `merge` tests membership against the
86/// *list* (`rtext not in self.low_numwords`), independently of `cards`.
87const LOW_NUMWORDS: [&str; 21] = [
88 "бист",
89 "нуздаҳ",
90 "ҳаждаҳ",
91 "ҳабдаҳ",
92 "шонздаҳ",
93 "понздаҳ",
94 "чордаҳ",
95 "сенздаҳ",
96 "дувоздаҳ",
97 "ёздаҳ",
98 "даҳ",
99 "нӯҳ",
100 "ҳашт",
101 "ҳафт",
102 "шаш",
103 "панҷ",
104 "чор",
105 "се",
106 "ду",
107 "як",
108 "сифр",
109];
110
111/// `Num2Word_TG.CURRENCY_FORMS`, verbatim from the class body.
112///
113/// **This table is TG's own class attribute, so the `lang_EUR` mutation trap
114/// does not apply here.** `Num2Word_EN.__init__` rewrites
115/// `Num2Word_EUR.CURRENCY_FORMS` in place (`self.CURRENCY_FORMS["EUR"] = ...`)
116/// and adds ~24 codes to it, which leaks into every subclass that *inherits*
117/// the dict. TG rebinds `CURRENCY_FORMS` in its own class body, so it shadows
118/// EUR's attribute entirely: EN's writes land on the ancestor and are never
119/// seen. Four codes is all Tajik has, and the corpus agrees — GBP, JPY, KWD,
120/// BHD, INR, CNY and CHF all raise NotImplementedError for `tg` even though
121/// English-inheriting languages resolve them.
122///
123/// Both forms of every entry are identical: Tajik does not inflect these nouns
124/// for number, so `pluralize`'s index choice is unobservable. The two-element
125/// arity is kept exactly as Python has it — `pluralize` indexes `forms[1]` for
126/// every `n != 1`, and collapsing to one form would turn that into an
127/// IndexError.
128fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
129 // lang_TG.GENERIC_DOLLARS / GENERIC_CENTS — note both are singular-shaped,
130 // unlike lang_EUR's ("dollar", "dollars").
131 const GENERIC_DOLLARS: [&str; 2] = ["доллар", "доллар"];
132 const GENERIC_CENTS: [&str; 2] = ["сент", "сент"];
133
134 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
135 // "repalced by EUR" [sic — the typo is in the Python source]
136 m.insert("EUR", CurrencyForms::new(&["евро", "евро"], &GENERIC_CENTS));
137 // "replaced by EUR"
138 m.insert("USD", CurrencyForms::new(&GENERIC_DOLLARS, &GENERIC_CENTS));
139 m.insert("RUB", CurrencyForms::new(&["рубл", "рубл"], &["копейк", "копейк"]));
140 m.insert("TJS", CurrencyForms::new(&["сомонӣ", "сомонӣ"], &["дирам", "дирам"]));
141 m
142}
143
144/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged.
145///
146/// TG defines no `CURRENCY_ADJECTIVES` of its own, so it reads EUR's — and
147/// unlike `CURRENCY_FORMS`, EN never mutates this dict, so what EUR's class
148/// body declares is what TG sees. The list is therefore transcribed in full
149/// rather than trimmed to TG's four codes, because that is literally the
150/// object Python hands to `currency in self.CURRENCY_ADJECTIVES`.
151///
152/// **Quirk reproduced**: only USD and RUB intersect TG's `CURRENCY_FORMS`, and
153/// their adjectives are the inherited *English* ones. So
154/// `to_currency(2, "USD", adjective=True)` is "ду US доллар" and RUB is
155/// "ду Russian рубл" — English adjectives glued onto Tajik nouns. The other 14
156/// entries are unreachable: `to_currency` looks `CURRENCY_FORMS` up first and
157/// raises NotImplementedError before the adjective branch can fire.
158fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
159 [
160 ("AUD", "Australian"),
161 ("BYN", "Belarusian"),
162 ("CAD", "Canadian"),
163 ("EEK", "Estonian"),
164 ("USD", "US"),
165 ("RUB", "Russian"),
166 ("NOK", "Norwegian"),
167 ("MXN", "Mexican"),
168 ("RON", "Romanian"),
169 ("INR", "Indian"),
170 ("HUF", "Hungarian"),
171 ("ISK", "íslenskar"),
172 ("UZS", "Uzbekistan"),
173 ("SAR", "Saudi"),
174 ("JPY", "Japanese"),
175 ("KRW", "Korean"),
176 ]
177 .into_iter()
178 .collect()
179}
180
181pub struct LangTg {
182 cards: Cards,
183 maxval: BigInt,
184 exclude_title: Vec<String>,
185 hundred: BigInt,
186 currency_forms: HashMap<&'static str, CurrencyForms>,
187 currency_adjectives: HashMap<&'static str, &'static str>,
188}
189
190impl Default for LangTg {
191 fn default() -> Self {
192 Self::new()
193 }
194}
195
196impl LangTg {
197 pub fn new() -> Self {
198 let mut cards = Cards::new();
199
200 // TG.set_high_numwords(high), high = gen_high_numwords([], [], lows) = lows.
201 // cap = 3 * (len(high) + 1) = 18; zip(high, range(cap, 5, -3)).
202 let high = ["квинт", "квадр", "тр", "м", "м"];
203 let cap = 3 * (high.len() as i64 + 1);
204 let mut n = cap;
205 for word in high.iter() {
206 // range(cap, 5, -3) is exhausted at n <= 5; zip() stops with it.
207 if n <= 5 {
208 break;
209 }
210 let suffix = if n == 9 { GIGA_SUFFIX } else { MEGA_SUFFIX };
211 cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}{}", word, suffix));
212 n -= 3;
213 }
214
215 set_mid_numwords(
216 &mut cards,
217 &[
218 (1000, "ҳазор"),
219 (100, "сад"),
220 (90, "навад"),
221 (80, "ҳаштод"),
222 (70, "ҳафтод"),
223 (60, "шаст"),
224 (50, "панҷоҳ"),
225 (40, "чил"),
226 (30, "си"),
227 ],
228 );
229 set_low_numwords(&mut cards, &LOW_NUMWORDS);
230
231 // MAXVAL = 1000 * list(self.cards.keys())[0]. First-inserted key is
232 // 10**18, which is also the maximum → 10**21.
233 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
234
235 LangTg {
236 cards,
237 maxval,
238 exclude_title: vec!["ва".into(), "минус".into(), "нуқта".into()],
239 hundred: BigInt::from(100),
240 // Built once here, never per call. `to_currency`/`to_cheque` only
241 // ever read these tables, so rebuilding them per invocation would
242 // be pure overhead on the hot path.
243 currency_forms: build_currency_forms(),
244 currency_adjectives: build_currency_adjectives(),
245 }
246 }
247
248 /// `Num2Word_Base.verify_ordinal`. The float branch is unreachable for
249 /// integer input; only the negative branch can fire.
250 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
251 if value.is_negative() {
252 return Err(N2WError::Type(format!(
253 "Cannot treat negative num {} as ordinal.",
254 value
255 )));
256 }
257 Ok(())
258 }
259
260 /// `Num2Word_Base.verify_ordinal` over the float/Decimal entry, plus the
261 /// truncating `int(value)` the two checks perform first: NaN raises
262 /// ValueError and ±inf OverflowError inside the first comparison;
263 /// fractional values are `errmsg_floatord`, negative whole ones
264 /// `errmsg_negord` (both TypeError). `-0.0` passes both checks and
265 /// ordinalises as zero → "сифрум". Returns the whole value as the
266 /// integer the ordinal path then renders.
267 fn verify_ordinal_float(&self, value: &crate::floatpath::FloatValue) -> Result<BigInt> {
268 if let crate::floatpath::FloatValue::Float { value: f, .. } = value {
269 if f.is_nan() {
270 return Err(N2WError::Value(
271 "cannot convert float NaN to integer".into(),
272 ));
273 }
274 if f.is_infinite() {
275 return Err(N2WError::Overflow(
276 "cannot convert float infinity to integer".into(),
277 ));
278 }
279 }
280 match value.as_whole_int() {
281 None => Err(N2WError::Type(
282 "Cannot treat float as ordinal.".into(),
283 )),
284 Some(i) if i.is_negative() => Err(N2WError::Type(format!(
285 "Cannot treat negative num {} as ordinal.",
286 i
287 ))),
288 Some(i) => Ok(i),
289 }
290 }
291}
292
293impl Lang for LangTg {
294 /// This language's own `to_currency(currency=...)` default,
295 /// read from the live Python signature. Only 44 of 156 use EUR.
296 fn default_currency(&self) -> &str {
297 "EUR"
298 }
299
300 /// This language's own `to_currency(separator=...)` default,
301 /// read from the live Python signature. Base's is ",", but only
302 /// 36 of 149 languages actually use it — most default to " " or a
303 /// conjunction, so inheriting Base's comma silently corrupts them.
304 fn default_separator(&self) -> &str {
305 ","
306 }
307
308 fn cards(&self) -> &Cards {
309 &self.cards
310 }
311 fn maxval(&self) -> &BigInt {
312 &self.maxval
313 }
314 fn negword(&self) -> &str {
315 "минус "
316 }
317 fn pointword(&self) -> &str {
318 "нуқта"
319 }
320 fn exclude_title(&self) -> &[String] {
321 &self.exclude_title
322 }
323
324 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
325 let (ltext, lnum) = l;
326 let (rtext, rnum) = r;
327 let hundred = &self.hundred;
328
329 if lnum.is_one() && rnum < hundred {
330 (rtext.to_string(), rnum.clone())
331 } else if hundred > lnum && lnum > rnum {
332 if ltext == "си" {
333 (format!("{}ю {}", ltext, rtext), lnum + rnum)
334 } else if ltext == "панҷоҳ" {
335 // Python drops ltext entirely here and hard-codes the stem.
336 (format!("панҷову {}", rtext), lnum + rnum)
337 } else {
338 (format!("{}у {}", ltext, rtext), lnum + rnum)
339 }
340 } else if lnum >= hundred && hundred > rnum {
341 (format!("{}у {}", ltext, rtext), lnum + rnum)
342 } else if rnum > lnum {
343 if ltext == "яксад" && !LOW_NUMWORDS.contains(&rtext) {
344 (format!("сад {}", rtext), lnum * rnum)
345 } else if rtext == "сад" {
346 // Glued, no space: 2 * 100 → "дусад".
347 (format!("{}{}", ltext, rtext), lnum * rnum)
348 } else {
349 (format!("{} {}", ltext, rtext), lnum * rnum)
350 }
351 } else {
352 (format!("{}у {}", ltext, rtext), lnum + rnum)
353 }
354 }
355
356 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
357 let mut out = String::new();
358 let mut v = value.clone();
359 if v.is_negative() {
360 v = v.abs();
361 // TG uses `self.negword` raw (base uses "%s " % negword.strip());
362 // identical here because negword already ends in a space.
363 out = self.negword().to_string();
364 }
365
366 if v >= self.maxval {
367 return Err(N2WError::Overflow(format!(
368 "abs({}) must be less than {}.",
369 v, self.maxval
370 )));
371 }
372
373 // The 100 special case runs *after* the sign strip, so -100 is
374 // "минус сад".
375 if v == self.hundred {
376 return Ok(self.title(&format!("{}сад", out)));
377 }
378
379 // `splitnum` cannot return None here: `cards` contains 0 and `v >= 0`,
380 // so the scan always finds a card. (Python would TypeError unpacking
381 // None if it ever did.)
382 let tree = splitnum(self, &v).ok_or_else(|| {
383 N2WError::Type(format!("type({}) not in [long, int, float]", v))
384 })?;
385 let words = match clean(self, tree) {
386 Node::Leaf(t, _) => t,
387 Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
388 };
389 Ok(self.title(&format!("{}{}", out, words)))
390 }
391
392 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
393 self.verify_ordinal(value)?;
394 let cardinal = self.to_cardinal(value)?;
395 // Python: cardinal.split(" ") — never empty, so last() always exists.
396 let lastword = cardinal.split(' ').next_back().unwrap_or("");
397 if lastword == "ду" || lastword == "се" || lastword == "си" {
398 Ok(format!("{}юм", cardinal))
399 } else {
400 Ok(format!("{}ум", cardinal))
401 }
402 }
403
404 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
405 self.verify_ordinal(value)?;
406 let ord = self.to_ordinal(value)?;
407 // Python: "%s%s" % (value, self.to_ordinal(value)[-2:]) — last two
408 // *characters*, not bytes (every suffix here is Cyrillic).
409 let n = ord.chars().count();
410 let suffix: String = ord.chars().skip(n.saturating_sub(2)).collect();
411 Ok(format!("{}{}", value, suffix))
412 }
413
414 /// `to_ordinal(float/Decimal)`: TG's `to_ordinal` opens with
415 /// `verify_ordinal(value)`, so a fractional or negative value raises
416 /// TypeError; a whole non-negative one rides the base `to_cardinal`
417 /// whole-value route into the integer ordinal — `5.0` → "панҷум",
418 /// `2.0` → "дуюм" (the last-word "ду"/"се"/"си" rule picks "юм").
419 fn ordinal_float_entry(&self, value: &crate::floatpath::FloatValue) -> Result<String> {
420 let i = self.verify_ordinal_float(value)?;
421 self.to_ordinal(&i)
422 }
423
424 /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)`, then
425 /// `"%s%s" % (value, self.to_ordinal(value)[-2:])` — the repr verbatim
426 /// plus the last two *characters* of the spoken ordinal of the same
427 /// (whole) value: `5.0` → "5.0ум", `2.0` → "2.0юм",
428 /// `Decimal("5.00")` → "5.00ум", `-0.0` → "-0.0ум"; `0.5`/`-1.0` raise
429 /// TypeError from the verify.
430 fn ordinal_num_float_entry(
431 &self,
432 value: &crate::floatpath::FloatValue,
433 repr_str: &str,
434 ) -> Result<String> {
435 let i = self.verify_ordinal_float(value)?;
436 let ord = self.to_ordinal(&i)?;
437 let n = ord.chars().count();
438 let suffix: String = ord.chars().skip(n.saturating_sub(2)).collect();
439 Ok(format!("{}{}", repr_str, suffix))
440 }
441
442 // to_year is intentionally NOT overridden: neither TG, EUR nor Base
443 // define anything but `to_year(value) -> self.to_cardinal(value)`, which
444 // is exactly the trait default.
445
446 // ---- currency -------------------------------------------------------
447 //
448 // Verified against the live interpreter: `to_currency`, `to_cheque`,
449 // `_money_verbose`, `_cents_verbose` and `_cents_terse` all resolve to
450 // `Num2Word_Base` for TG, and `pluralize` to `Num2Word_EUR`. So the only
451 // language-specific things are the class name, the two data tables and
452 // that one plural rule — the trait defaults already mirror everything
453 // else, and are deliberately left alone.
454 //
455 // `currency_precision` is **not** overridden: TG's `CURRENCY_PRECISION` is
456 // Base's empty dict (EN *rebinds* rather than mutates it, in `__init__`,
457 // so its 3-decimal entries never leak), which makes
458 // `CURRENCY_PRECISION.get(code, 100)` unconditionally 100 — exactly the
459 // trait default. TG therefore has no 3-decimal and no 0-decimal currency:
460 // KWD/BHD carry divisor 100, not 1000, and JPY 100, not 1. All four are
461 // absent from `CURRENCY_FORMS` anyway, so they raise NotImplementedError
462 // before precision is ever consulted, and `default_to_currency`'s
463 // `divisor == 1` branch is unreachable for Tajik.
464
465 fn lang_name(&self) -> &str {
466 "Num2Word_TG"
467 }
468
469 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
470 self.currency_forms.get(code)
471 }
472
473 fn currency_adjective(&self, code: &str) -> Option<&str> {
474 self.currency_adjectives.get(code).copied()
475 }
476
477 /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
478 ///
479 /// Python indexes the tuple directly, so a one-form entry with `n != 1`
480 /// would raise IndexError. Every TG entry has exactly two forms, so this
481 /// is unreachable — but it is mapped to `Index` rather than panicking so
482 /// the exception *type* survives if the table ever changes.
483 ///
484 /// Tajik spells both forms identically in all four entries, so the branch
485 /// is output-invisible; it is ported faithfully rather than folded away.
486 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
487 let form = if n.is_one() { 0 } else { 1 };
488 forms
489 .get(form)
490 .cloned()
491 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
492 }
493}