Skip to main content

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}