Skip to main content

num2words2_core/
lang_sq.rs

1//! Port of `lang_SQ.py` (Albanian).
2//!
3//! Registry check: `CONVERTER_CLASSES["sq"]` is `lang_SQ.Num2Word_SQ()`, which
4//! is the class ported here.
5//!
6//! Shape: **engine**. `Num2Word_SQ` subclasses `Num2Word_Base` directly and
7//! defines `high_numwords`/`mid_numwords`/`low_numwords` + `merge`, so
8//! `Num2Word_Base.__init__` builds `self.cards` and `MAXVAL`, and
9//! `Num2Word_Base.to_cardinal` drives `splitnum`/`clean`/`merge`. SQ overrides
10//! `to_cardinal` only to short-circuit zero before delegating to `super()`.
11//!
12//! Inherited from `Num2Word_Base` and left alone by SQ:
13//!   * `is_title = False` — so `title()` is the identity and `exclude_title`
14//!     (`["e", "presje", "minus"]`) is **dead**. It is still declared below to
15//!     mirror the source; it can never fire while `is_title()` stays false.
16//!   * `verify_ordinal` — reproduced as [`verify_ordinal`]; only
17//!     `to_ordinal_num` calls it (see bug 3).
18//!   * `errmsg_toobig` = `"abs(%s) must be less than %s."`, emitted by
19//!     `default_to_cardinal` in `base.rs`.
20//!
21//! # Card table and MAXVAL
22//!
23//! `set_high_numwords` is overridden but does **not** append an "illion"
24//! suffix the way EN does — the words go in verbatim:
25//!
26//! ```text
27//! high = ["trilion", "miliard", "milion"]; max = 3 + 3*3 = 12
28//! zip(high, range(12, 3, -3)) -> ("trilion",12), ("miliard",9), ("milion",6)
29//! ```
30//!
31//! So the top card is `10**12` ("trilion") and
32//! `MAXVAL = 1000 * 10**12 = 10**15`. Python takes `list(cards.keys())[0]`
33//! (insertion order, high words first) which coincides with the maximum key
34//! here, so `Cards::highest()` gives the identical answer. Anything `>= 10**15`
35//! raises `OverflowError`, which is why `to_cardinal(10**15)` fails while
36//! `to_cardinal(10**12)` == "një trilion".
37//!
38//! Note `10**12` is named "trilion" and `10**9` "miliard" — a long-scale
39//! labelling that makes SQ's "trilion" an English *trillion* but its "miliard"
40//! an English *billion*. That is what the table says; not a porting slip.
41//!
42//! # Faithfully reproduced Python bugs / oddities
43//!
44//! 1. **Hundreds above 100 are two words.** `merge` special-cases only
45//!    `cnum == 1` into the fused "njëqind", so 100 -> "njëqind" but
46//!    200 -> "dy qind", 555 -> "pesë qind e pesëdhjetë e pesë". Idiomatic
47//!    Albanian writes "dyqind"/"pesëqind"; Python does not, so neither do we.
48//! 2. **`to_ordinal` is barely ordinal.** Only the 14 keys in `ordinal_forms`
49//!    (0..=10, 20, 100, 1000) get real ordinal words. Everything else is just
50//!    `"i " + to_cardinal(value)` — e.g. `to_ordinal(11)` ==
51//!    "i njëmbëdhjetë" (the bare cardinal), `to_ordinal(30)` == "i tridhjetë".
52//!    Also note `ordinal_forms[20]` is "i njezeti" — missing the diaeresis
53//!    that the cardinal "njëzet" has. Kept verbatim.
54//! 3. **`to_ordinal` accepts negatives; `to_ordinal_num` does not.**
55//!    `to_ordinal` never calls `verify_ordinal`, and every negative satisfies
56//!    its `value <= 10` branch, whose `.get(value, default)` default is
57//!    evaluated eagerly — so `to_ordinal(-1)` == "i minus një". Meanwhile
58//!    `to_ordinal_num(-1)` raises `TypeError`. The asymmetry is real.
59//! 4. **Dead branches, ported as written.** In `to_ordinal` the
60//!    `elif value == 20 / == 100 / == 1000` arms are unreachable (those keys
61//!    are in `ordinal_forms`, caught by the first check), and the `value <= 10`
62//!    arm can only ever return its default. In `to_ordinal_num` the
63//!    `value % 10 == 3` arm and the `else` arm both yield "-ti", so only
64//!    "-shi" (1) and "-ri" (2) are ever distinct.
65//! 5. **An unknown currency is not an error.** `to_currency` ends with
66//!    `else: return self.to_cardinal(val)` instead of raising
67//!    `NotImplementedError`, so `to_currency(12.34, "KWD")` yields the bare
68//!    number "dymbëdhjetë presje tre katër" — no currency name at all. Worse,
69//!    that fallback runs *after* `val = abs(val)`, and the `is_negative` flag
70//!    is only consumed in the known-currency branch: `to_currency(-12.34,
71//!    "KWD")` silently loses its sign. Both are corpus-confirmed.
72//! 6. **`to_currency` ignores `CURRENCY_PRECISION` and `adjective`.** The
73//!    divisor is hard-coded to 100, so JPY — a real 0-decimal currency, and
74//!    one SQ lists in `CURRENCY_FORMS` — is given "senë" subunits:
75//!    `to_currency(12.34, "JPY")` == "dymbëdhjetë jenë, tridhjetë e katër
76//!    senë". The `adjective` parameter is accepted and never read.
77//! 7. **`cents=False` drops the cents rather than abbreviating them.** SQ
78//!    never calls `_cents_terse`; the whole segment is behind
79//!    `if cents_value > 0 and cents`, so `to_currency(12.34, "EUR",
80//!    cents=False)` == "dymbëdhjetë euro" — the ",34" is simply gone.
81//! 8. **`to_cheque` contradicts `to_currency` on which codes exist.** SQ does
82//!    not override `to_cheque`, so it uses `Num2Word_Base`'s, which *does*
83//!    raise `NotImplementedError` on a missing code. KWD therefore raises for
84//!    cheques while quietly degrading to a bare cardinal for currency. Base's
85//!    version also reads `CURRENCY_PRECISION` (empty here → 100), which is why
86//!    `to_cheque(1234.56, "JPY")` is "... AND 56/100 JENË".
87//!
88//! # Currency shape
89//!
90//! `to_currency` and `pluralize` are overridden outright — `Num2Word_Base`'s
91//! `to_currency`, `parse_currency_parts`, `_money_verbose`, `_cents_verbose`
92//! and `_cents_terse` are all bypassed, so the shared `default_to_currency` is
93//! not used. `to_cheque` is *not* overridden and rides the base default, which
94//! does reach `money_verbose`. `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION`
95//! are both left at `Num2Word_Base`'s empty dicts, so their hooks keep the
96//! trait defaults.
97//!
98//! SQ's `to_currency` reaches the float path (`to_cardinal_float`) two ways —
99//! the unknown-currency fallback and the fractional-cents branch — so
100//! `float2tuple`/`to_cardinal_float` are ported here even though the general
101//! float/Decimal *cardinal* surface belongs to a later phase.
102//!
103//! # Float / Decimal cardinal surface
104//!
105//! Now wired: [`Lang::to_cardinal_float`] (see the `impl Lang` block) serves
106//! the `cardinal`/`cardinal_dec` float and Decimal surfaces. SQ overrides
107//! `to_cardinal` (only to short-circuit zero) but *not*
108//! `Num2Word_Base.to_cardinal_float` or `float2tuple` (checked against the live
109//! class), so its float cardinal path is the base engine's, reached as
110//! `SQ.to_cardinal(v)` → `super().to_cardinal(v)` → `to_cardinal_float(v)` for
111//! non-integral `v`. The override therefore delegates to
112//! `floatpath::default_to_cardinal_float`, the faithful port of that base
113//! method, which renders the integer part and each fractional digit back
114//! through `self.to_cardinal` (so 0 → "zero"). Verified byte-for-byte against
115//! all 24 sq `cardinal`/`cardinal_dec` corpus rows.
116//!
117//! It must **not** be hung on the private `to_cardinal_decimal` /
118//! `to_cardinal_float(f64)` methods below — those exist for the currency
119//! fractional-cents branch, where Python casts to `float` first. A Decimal
120//! *cardinal* keeps full precision: `num2words(Decimal("98746251323029.99"))`
121//! takes base `float2tuple`'s exact Decimal arm (issue #603). Routing it
122//! through an `f64` cast reprs the value as `98746251323029.98` and yields
123//! "… presje nëntë tetë" instead of the correct "… presje nëntë nëntë".
124//! `default_to_cardinal_float` preserves the `FloatValue::Decimal` arm, so the
125//! exact digits survive.
126//!
127//! # Out of scope
128//!
129//! `to_fraction` is a later phase.
130
131use crate::base::{
132    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result,
133};
134use crate::currency::{CurrencyForms, CurrencyValue};
135use crate::floatpath::{default_to_cardinal_float, FloatValue};
136use bigdecimal::BigDecimal;
137use num_bigint::BigInt;
138use num_integer::Integer;
139use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
140use std::collections::HashMap;
141
142pub struct LangSq {
143    cards: Cards,
144    maxval: BigInt,
145    exclude_title: Vec<String>,
146    /// `CURRENCY_FORMS`, built once. Rebuilding it per call is what made an
147    /// earlier revision of this port slower than the Python it replaces.
148    currency_forms: HashMap<&'static str, CurrencyForms>,
149}
150
151/// `cents_value` in `Num2Word_SQ.to_currency` is a Python `int` on the
152/// ordinary path but a `Decimal` on the fractional-cents path, and the code
153/// then branches on `isinstance(cents_value, Decimal)` to pick between
154/// `to_cardinal` and `to_cardinal_float`. The two are not interchangeable, so
155/// the distinction is modelled rather than collapsed to one numeric type.
156enum SqCents {
157    Int(BigInt),
158    Dec(BigDecimal),
159}
160
161impl Default for LangSq {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167/// Port of `Num2Word_Base.verify_ordinal`.
168///
169/// The float check (`errmsg_floatord`) cannot fire for `BigInt` input, so only
170/// the negative check survives. Python raises `TypeError`, not `ValueError`.
171fn verify_ordinal(value: &BigInt) -> Result<()> {
172    if value.is_negative() {
173        return Err(N2WError::Type(format!(
174            "Cannot treat negative num {} as ordinal.",
175            value
176        )));
177    }
178    Ok(())
179}
180
181/// Port of `self.ordinal_forms` — the only genuine ordinal words SQ knows.
182///
183/// Keys are 0..=10, 20, 100, 1000. Every other value falls through to
184/// `"i " + to_cardinal(...)`. `to_i64` is the membership test: no key exceeds
185/// 1000, so anything that does not fit an `i64` is definitionally absent.
186fn ordinal_form(value: &BigInt) -> Option<&'static str> {
187    match value.to_i64()? {
188        0 => Some("i zeroi"),
189        1 => Some("i pari"),
190        2 => Some("i dyti"),
191        3 => Some("i treti"),
192        4 => Some("i katërti"),
193        5 => Some("i pesti"),
194        6 => Some("i gjashti"),
195        7 => Some("i shtati"),
196        8 => Some("i teti"),
197        9 => Some("i nënti"),
198        10 => Some("i dhjeti"),
199        // "i njezeti", not "i njëzeti" — verbatim from Python.
200        20 => Some("i njezeti"),
201        100 => Some("i njëqindi"),
202        1000 => Some("i një mijti"),
203        _ => None,
204    }
205}
206
207impl LangSq {
208    pub fn new() -> Self {
209        let mut cards = Cards::new();
210
211        // SQ's set_high_numwords: max = 3 + 3*len(high); zip(high, range(max, 3, -3)).
212        // No "illion" suffix is appended — the words are stored as given.
213        let high = ["trilion", "miliard", "milion"];
214        let max = 3 + 3 * high.len() as i64;
215        let mut n = max;
216        for word in high.iter() {
217            if n <= 3 {
218                break;
219            }
220            cards.insert(BigInt::from(10u8).pow(n as u32), *word);
221            n -= 3;
222        }
223
224        set_mid_numwords(
225            &mut cards,
226            &[
227                (1000, "mijë"),
228                (100, "qind"),
229                (90, "nëntëdhjetë"),
230                (80, "tetëdhjetë"),
231                (70, "shtatëdhjetë"),
232                (60, "gjashtëdhjetë"),
233                (50, "pesëdhjetë"),
234                (40, "dyzet"),
235                (30, "tridhjetë"),
236                (20, "njëzet"),
237            ],
238        );
239        // 20 words -> cards 19 down to 0.
240        set_low_numwords(
241            &mut cards,
242            &[
243                "nëntëmbëdhjetë",
244                "tetëmbëdhjetë",
245                "shtatëmbëdhjetë",
246                "gjashtëmbëdhjetë",
247                "pesëmbëdhjetë",
248                "katërmbëdhjetë",
249                "trembëdhjetë",
250                "dymbëdhjetë",
251                "njëmbëdhjetë",
252                "dhjetë",
253                "nëntë",
254                "tetë",
255                "shtatë",
256                "gjashtë",
257                "pesë",
258                "katër",
259                "tre",
260                "dy",
261                "një",
262                "zero",
263            ],
264        );
265
266        // MAXVAL = 1000 * highest card = 1000 * 10**12 = 10**15.
267        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
268
269        // CURRENCY_FORMS. Every entry is a 2-tuple of 2-tuples; SQ's
270        // `pluralize` only ever indexes [0] and [1], but the arity is kept as
271        // written. Note "qindarkë" and "paund" repeat across singular and
272        // plural, and CHF's plural "franka zvicer" drops the -an — verbatim.
273        let mut currency_forms = HashMap::new();
274        currency_forms.insert("ALL", CurrencyForms::new(&["lek", "lekë"], &["qindarkë", "qindarkë"]));
275        currency_forms.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "centë"]));
276        currency_forms.insert("USD", CurrencyForms::new(&["dollar", "dollarë"], &["cent", "centë"]));
277        currency_forms.insert("GBP", CurrencyForms::new(&["paund", "paund"], &["peni", "pence"]));
278        currency_forms.insert(
279            "CHF",
280            CurrencyForms::new(&["frank zviceran", "franka zvicer"], &["centim", "centimë"]),
281        );
282        currency_forms.insert("JPY", CurrencyForms::new(&["jen", "jenë"], &["sen", "senë"]));
283        currency_forms.insert("RUB", CurrencyForms::new(&["rubël", "rubla"], &["kopek", "kopekë"]));
284
285        LangSq {
286            cards,
287            maxval,
288            // Dead while is_title() is false, but declared as the source does.
289            exclude_title: vec!["e".into(), "presje".into(), "minus".into()],
290            currency_forms,
291        }
292    }
293
294    /// Port of `Num2Word_Base.float2tuple(float(value))`, returning
295    /// `(pre, precision, post)`.
296    ///
297    /// Takes an `f64` because Python's does: every caller reaching it goes
298    /// through `float(...)` first, and each step below is sensitive to the
299    /// cast. Python stashes `precision` on `self` for `to_cardinal_float` to
300    /// read back; it is returned here instead, since nothing else observes
301    /// that state and `&self` is shared.
302    ///
303    /// The arithmetic is deliberately left in `f64` rather than redone in
304    /// exact decimal. Python evaluates `abs(value - pre) * 10**precision` on
305    /// binary floats and guards it with `abs(round(post) - post) < 0.01`, a
306    /// heuristic meant to rescue 1.239999999 into 1.24. When a value's repr
307    /// needs all 17 significant digits the binary noise dwarfs that tolerance,
308    /// so the `math.floor` arm fires and truncates the last digit instead:
309    /// `float2tuple(30.000000000000004)` yields post=3, not 4. Exact decimal
310    /// arithmetic would silently "fix" that and diverge from Python.
311    ///
312    /// Fallible only for the `10**precision` conversion — see below.
313    fn float2tuple(&self, value: f64) -> Result<(BigInt, usize, BigInt)> {
314        // pre = int(value) — truncates toward zero.
315        let pre_f = value.trunc();
316        let pre = BigInt::from_f64(pre_f).unwrap_or_else(BigInt::zero);
317
318        // self.precision = abs(Decimal(str(value)).as_tuple().exponent).
319        //
320        // `str(float)` is Python's shortest round-tripping repr, and Rust's
321        // `Display` for f64 is the same shortest-round-trip algorithm, so the
322        // digits agree. The formats differ only in that Python switches to
323        // exponent notation (`1e-05`) where Rust stays positional
324        // (`0.00001`) — and `Decimal("1E-5").as_tuple().exponent` is -5,
325        // i.e. exactly the count of fractional digits in the positional form.
326        // So counting the digits after '.' reproduces Python's exponent.
327        //
328        // Deriving this from the value's own `str` is what matters: the exact
329        // decimal can be *longer* than the repr (float(Decimal("65.65924651\
330        // 349251")) reprs as "65.6592465134925"), and using the longer one
331        // would append a spurious trailing digit to the output.
332        //
333        // Integral floats have no '.' in Rust's output but repr them as "5.0";
334        // hence the fallback of 1. (Unreachable: to_cardinal_decimal routes
335        // integral values to the int engine, and the fractional-cents branch
336        // only ever produces non-integral values. Values >= 1e16, where
337        // Python's repr goes exponential, are integral and so equally out of
338        // reach.)
339        let repr = format!("{}", value);
340        let precision = repr.split_once('.').map_or(1, |(_, frac)| frac.len());
341
342        // `10**precision` is an exact Python int that the multiply then has to
343        // convert to float, so build it the same way. `powi` would drift by an
344        // ulp past 10**22, where powers of ten stop being exactly
345        // representable, and — more importantly — would quietly produce `inf`
346        // where Python raises.
347        //
348        // Past 10**308 the int no longer fits a double and CPython's
349        // `float * int` raises OverflowError rather than returning inf. That
350        // is reachable: repr(5e-324) is "5e-324", so precision is 324 and
351        // `to_currency(5e-324, "EUR")` raises instead of answering.
352        let scale = match BigInt::from(10).pow(precision as u32).to_f64() {
353            Some(s) if s.is_finite() => s,
354            _ => {
355                return Err(N2WError::Overflow(
356                    "int too large to convert to float".to_string(),
357                ))
358            }
359        };
360        let post_f = (value - pre_f).abs() * scale;
361
362        // Python's round() is half-even, hence round_ties_even and not round.
363        let rounded = post_f.round_ties_even();
364        let post = if (rounded - post_f).abs() < 0.01 {
365            BigInt::from_f64(rounded)
366        } else {
367            BigInt::from_f64(post_f.floor())
368        };
369        // from_f64 only fails on NaN/inf, which a finite `scale` rules out.
370        Ok((pre, precision, post.unwrap_or_else(BigInt::zero)))
371    }
372
373    /// Port of `Num2Word_Base.to_cardinal_float` (SQ does not override it).
374    ///
375    /// The `precision=None` parameter is not modelled: no SQ path passes one,
376    /// so `float2tuple` always decides it from `str(value)`.
377    fn to_cardinal_float(&self, value: f64) -> Result<String> {
378        let (pre, precision, post) = self.float2tuple(value)?;
379
380        // post = str(post); post = "0" * (self.precision - len(post)) + post.
381        // Python's `"0" * negative` is "", so a too-long post is not padded —
382        // and is then read only up to `precision` by the loop below.
383        let mut post_s = post.to_string();
384        let len = post_s.chars().count();
385        if precision > len {
386            post_s = "0".repeat(precision - len) + &post_s;
387        }
388
389        let mut out = vec![self.to_cardinal(&pre)?];
390        // `if value < 0 and pre == 0` — to_cardinal(0) is "zero" with no minus
391        // to carry the sign, so -0.5 would otherwise come out positive.
392        if value < 0.0 && pre.is_zero() {
393            out.insert(0, self.negword().trim().to_string());
394        }
395        if precision > 0 {
396            out.push(self.title(self.pointword()));
397        }
398        for ch in post_s.chars().take(precision) {
399            // Python: `curr = int(post[i])` — one character, always a digit.
400            let digit = ch.to_digit(10).expect("float2tuple yields digits only");
401            out.push(self.to_cardinal(&BigInt::from(digit))?);
402        }
403        Ok(out.join(" "))
404    }
405
406    /// Port of `Num2Word_SQ.to_cardinal` for the case where Python's argument
407    /// is a `float` rather than an `int`.
408    ///
409    /// `to_cardinal(0.0)` hits SQ's `value == 0` short-circuit. Otherwise
410    /// `Num2Word_Base.to_cardinal` runs `assert int(value) == value`: an
411    /// integral float passes and drives `splitnum` with a float, which for
412    /// SQ's card table yields exactly what the equivalent int would. (The one
413    /// place a float would break — the `div == value` tally arm, which does
414    /// `div * self.cards[elem]` and would raise TypeError on a float `div` —
415    /// needs elem==1, and elem 1 is only reached for values below 2, where
416    /// `div == 1` wins first. So it is unreachable.) A non-integral float
417    /// trips the assert and is caught into `to_cardinal_float`.
418    fn to_cardinal_decimal(&self, value: &BigDecimal) -> Result<String> {
419        if value.is_zero() {
420            return Ok("zero".to_string());
421        }
422        let truncated = value.with_scale(0);
423        if (value - &truncated).is_zero() {
424            return self.to_cardinal(&truncated.as_bigint_and_exponent().0);
425        }
426        self.to_cardinal_float(value.to_f64().unwrap_or(f64::NAN))
427    }
428}
429
430impl Lang for LangSq {
431    /// This language's own `to_currency(currency=...)` default,
432    /// read from the live Python signature. Only 44 of 156 use EUR.
433    fn default_currency(&self) -> &str {
434        "ALL"
435    }
436
437    /// This language's own `to_currency(separator=...)` default,
438    /// read from the live Python signature. Base's is ",", but only
439    /// 36 of 149 languages actually use it — most default to " " or a
440    /// conjunction, so inheriting Base's comma silently corrupts them.
441    fn default_separator(&self) -> &str {
442        ","
443    }
444
445    fn cards(&self) -> &Cards {
446        &self.cards
447    }
448    fn maxval(&self) -> &BigInt {
449        &self.maxval
450    }
451    fn negword(&self) -> &str {
452        "minus "
453    }
454    fn pointword(&self) -> &str {
455        "presje"
456    }
457    fn exclude_title(&self) -> &[String] {
458        &self.exclude_title
459    }
460
461    /// Port of `Num2Word_SQ.merge`.
462    ///
463    /// The fall-through order is load-bearing: when `cnum == 1` and `nnum` is
464    /// a big card (>= 10**6), Python rewrites `ctext` to "një" and keeps
465    /// falling, landing in the `nnum > cnum` block — that is what makes
466    /// 10**6 render "një milion" rather than "milion".
467    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
468        let (ctext, cnum) = l;
469        let (ntext, nnum) = r;
470        let mut ctext = ctext.to_string();
471
472        let hundred = BigInt::from(100);
473        let thousand = BigInt::from(1000);
474        let million = BigInt::from(1_000_000);
475        let twenty = BigInt::from(20);
476        let ten = BigInt::from(10);
477
478        if cnum.is_one() {
479            if nnum == &thousand {
480                return ("një mijë".to_string(), thousand);
481            }
482            if nnum == &hundred {
483                // Fused into one word — but only for 1. See module bug 1.
484                return ("njëqind".to_string(), hundred);
485            }
486            if nnum < &hundred {
487                return (ntext.to_string(), nnum.clone());
488            }
489            ctext = "një".to_string();
490        }
491
492        if nnum > cnum {
493            // All three arms are the same "%s %s" formatting in Python; kept
494            // separate to mirror the source's if/elif chain.
495            if nnum >= &million {
496                return (format!("{} {}", ctext, ntext), cnum * nnum);
497            } else if nnum >= &thousand {
498                return (format!("{} {}", ctext, ntext), cnum * nnum);
499            } else if nnum == &hundred {
500                return (format!("{} {}", ctext, ntext), cnum * nnum);
501            }
502            // Falls through to the tail return when nnum > cnum but nnum is
503            // none of the above (unreachable with SQ's card table).
504        }
505
506        if nnum < cnum {
507            if cnum >= &thousand {
508                return (format!("{} e {}", ctext, ntext), cnum + nnum);
509            } else if cnum >= &hundred {
510                return (format!("{} e {}", ctext, ntext), cnum + nnum);
511            } else {
512                if cnum >= &twenty && nnum < &ten {
513                    return (format!("{} e {}", ctext, ntext), cnum + nnum);
514                }
515                // No separator. Unreachable with SQ's card table: every card
516                // below 100 is a multiple of ten, so its remainder is < 10.
517                return (format!("{}{}", ctext, ntext), cnum + nnum);
518            }
519        }
520
521        (format!("{} {}", ctext, ntext), cnum + nnum)
522    }
523
524    /// Port of `Num2Word_SQ.to_cardinal`: zero short-circuits *before* the
525    /// overflow check, everything else goes to `Num2Word_Base.to_cardinal`.
526    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
527        if value.is_zero() {
528            return Ok("zero".to_string());
529        }
530        default_to_cardinal(self, value)
531    }
532
533    /// Port of `Num2Word_SQ.to_ordinal`.
534    ///
535    /// Note there is no `verify_ordinal` call — negatives are accepted and
536    /// produce "i minus ..." (module bug 3).
537    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
538        if let Some(form) = ordinal_form(value) {
539            return Ok(form.to_string());
540        }
541
542        // Python: `if value <= 10: return self.ordinal_forms.get(value, "i " +
543        // self.to_cardinal(value))`. Every dict key <= 10 was already returned
544        // above, so only the default can win — which for value <= 10 means the
545        // value is negative. The eager default is why to_cardinal runs at all.
546        if value <= &BigInt::from(10) {
547            return Ok(format!("i {}", self.to_cardinal(value)?));
548        }
549
550        // The `elif value == 20 / 100 / 1000` arms Python writes here are
551        // unreachable: all three are ordinal_forms keys. Only the else runs.
552        let cardinal = self.to_cardinal(value)?;
553        Ok(format!("i {}", cardinal))
554    }
555
556    /// Port of `Num2Word_SQ.to_ordinal_num`.
557    ///
558    /// `verify_ordinal` rejects negatives with `TypeError`, so the operands of
559    /// `%` are always non-negative and Python's floor-mod matches `mod_floor`.
560    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
561        verify_ordinal(value)?;
562
563        let m10 = value.mod_floor(&BigInt::from(10));
564        let m100 = value.mod_floor(&BigInt::from(100));
565
566        let suffix = if m10 == BigInt::one() && m100 != BigInt::from(11) {
567            "-shi"
568        } else if m10 == BigInt::from(2) && m100 != BigInt::from(12) {
569            "-ri"
570        } else if m10 == BigInt::from(3) && m100 != BigInt::from(13) {
571            // Same result as the else arm — a no-op branch in the source.
572            "-ti"
573        } else {
574            "-ti"
575        };
576
577        Ok(format!("{}{}", value, suffix))
578    }
579
580    /// Port of `Num2Word_SQ.to_year`: "same as cardinal for Albanian".
581    /// No century splitting, and negatives keep the "minus " prefix rather
582    /// than gaining a BC/AD suffix.
583    fn to_year(&self, value: &BigInt) -> Result<String> {
584        self.to_cardinal(value)
585    }
586
587    /// `to_ordinal(float/Decimal)` — Python's dict-membership gauntlet.
588    ///
589    /// `value in self.ordinal_forms` is *numeric* hash equality, so every
590    /// whole value equal to a key (5.0, `Decimal("5.00")`, `1E+2`, and
591    /// -0.0 == 0) takes the table form; everything else — fractional,
592    /// negative, or merely absent — lands on `"i " + self.to_cardinal(value)`
593    /// (the `value <= 10` arm's eager `.get` default and the tail `else` are
594    /// the same expression). No `verify_ordinal` here, so negatives render
595    /// as "i minus ..." and fractions as "i ... presje ...".
596    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
597        if let Some(i) = value.as_whole_int() {
598            if let Some(form) = ordinal_form(&i) {
599                return Ok(form.to_string());
600            }
601            // Whole but not a key: base to_cardinal routes a whole float
602            // through the integer path, so this is `to_cardinal(int)`.
603            return Ok(format!("i {}", self.to_cardinal(&i)?));
604        }
605        // Fractional: `int(value) == value` fails inside base to_cardinal,
606        // which falls through to the float grammar. (UFCS: the inherent
607        // `LangSq::to_cardinal_float(f64)` — the currency float-cast helper —
608        // would shadow the trait method here.)
609        Ok(format!("i {}", Lang::to_cardinal_float(self, value, None)?))
610    }
611
612    /// `to_ordinal_num(float/Decimal)`: `verify_ordinal` first — TypeError
613    /// for a fractional value ("Cannot treat float %s as ordinal.") before
614    /// the negative check ("Cannot treat negative num %s as ordinal.");
615    /// -0.0 passes both (`abs(-0.0) == -0.0`). Then `str(value)` + the
616    /// suffix picked by `value % 10` / `value % 100`, which for a whole
617    /// float agrees with integer arithmetic ("21.0-shi", "42.0-ri",
618    /// "12.0-ti").
619    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
620        let i = match value.as_whole_int() {
621            Some(i) => i,
622            None => {
623                return Err(N2WError::Type(format!(
624                    "Cannot treat float {} as ordinal.",
625                    repr_str
626                )))
627            }
628        };
629        if i.is_negative() {
630            return Err(N2WError::Type(format!(
631                "Cannot treat negative num {} as ordinal.",
632                repr_str
633            )));
634        }
635        let m10 = i.mod_floor(&BigInt::from(10));
636        let m100 = i.mod_floor(&BigInt::from(100));
637        let suffix = if m10 == BigInt::one() && m100 != BigInt::from(11) {
638            "-shi"
639        } else if m10 == BigInt::from(2) && m100 != BigInt::from(12) {
640            "-ri"
641        } else {
642            // The `% 10 == 3` arm is "-ti" too — a no-op branch in the source.
643            "-ti"
644        };
645        Ok(format!("{}{}", repr_str, suffix))
646    }
647
648    // ---- currency -------------------------------------------------------
649
650    fn lang_name(&self) -> &str {
651        "Num2Word_SQ"
652    }
653
654    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
655        self.currency_forms.get(code)
656    }
657
658    // `currency_adjective` and `currency_precision` are deliberately left at
659    // their trait defaults: SQ declares neither CURRENCY_ADJECTIVES nor
660    // CURRENCY_PRECISION, so both inherit Num2Word_Base's empty dict, and
661    // `CURRENCY_PRECISION.get(code, 100)` is 100 for every code. That default
662    // is what gives `to_cheque(1234.56, "JPY")` its "56/100" (bug 8).
663
664    /// Port of `Num2Word_SQ.pluralize` — singular for 1, plural otherwise.
665    ///
666    /// Infallible, but the trait returns `Result` because Python's
667    /// `Num2Word_Base.pluralize` is abstract and raises NotImplementedError.
668    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
669        // `if not forms: return ""`
670        let Some(first) = forms.first() else {
671            return Ok(String::new());
672        };
673        if forms.len() == 1 {
674            return Ok(first.clone());
675        }
676        // `if len(forms) >= 2:` — always true past the guards above, so
677        // Python's trailing `return forms[0]` is unreachable.
678        if n.is_one() {
679            Ok(first.clone())
680        } else {
681            Ok(forms[1].clone())
682        }
683    }
684
685    /// Port of the float / Decimal cardinal path.
686    ///
687    /// SQ does not override `Num2Word_Base.to_cardinal_float` or `float2tuple`
688    /// (verified against the live class), so its float cardinal surface is the
689    /// base engine's. `default_to_cardinal_float` is the faithful port of that
690    /// base method; it renders the integer part and every fractional digit via
691    /// `self.to_cardinal`, dispatching back through SQ's `to_cardinal` override
692    /// (so 0 → "zero"). The `precision=` kwarg is threaded through as
693    /// `precision_override` per the shared base contract (no sq corpus row
694    /// exercises it; note base `float2tuple` recomputes `self.precision` from
695    /// `str(value)`, so in Python the kwarg is clobbered — a `floatpath` matter,
696    /// identical for every language on the base path).
697    ///
698    /// Deliberately *not* routed through SQ's private `to_cardinal_decimal` /
699    /// `to_cardinal_float(f64)`: those are the currency float-cast path and
700    /// would collapse `FloatValue::Decimal`'s exact arm to an `f64`, so a
701    /// Decimal cardinal like `98746251323029.99` (issue #603) would lose its
702    /// last digit. `default_to_cardinal_float` keeps the Decimal arm exact.
703    fn to_cardinal_float(
704        &self,
705        value: &FloatValue,
706        precision_override: Option<u32>,
707    ) -> Result<String> {
708        default_to_cardinal_float(self, value, precision_override)
709    }
710
711    /// The hook stands for `self.to_cardinal(float(right))`, which is what
712    /// `Num2Word_Base.to_currency` calls on its fractional-cents branch — so
713    /// it maps to `to_cardinal_decimal` (SQ's `to_cardinal`), not straight to
714    /// `to_cardinal_float`. The two differ only for zero and integral values,
715    /// which that branch cannot produce.
716    ///
717    /// Unreachable for SQ: only `default_to_currency` consumes this hook and
718    /// SQ overrides `to_currency` outright. Wired up because the port exists
719    /// and this hook means exactly that expression.
720    fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
721        self.to_cardinal_decimal(value)
722    }
723
724    /// Port of `Num2Word_SQ.to_currency`, which replaces the base version
725    /// wholesale — no `parse_currency_parts`, no `_money_verbose`, no
726    /// `_cents_terse`, and no NotImplementedError for unknown codes.
727    fn to_currency(
728        &self,
729        val: &CurrencyValue,
730        currency: &str,
731        cents: bool,
732        separator: Option<&str>,
733        // Accepted and never read by SQ's to_currency (bug 6).
734        _adjective: bool,
735    ) -> Result<String> {
736        // Trait hands None when the caller omitted separator=;
737        // resolve to this language's own default.
738        let separator = separator.unwrap_or(self.default_separator());
739        let mut result: Vec<String> = Vec::new();
740
741        // is_negative = val < 0; val = abs(val)
742        let is_negative = val.is_negative();
743        let val = match val {
744            CurrencyValue::Int(v) => CurrencyValue::Int(v.abs()),
745            CurrencyValue::Decimal { value: v, is_float, .. } => CurrencyValue::Decimal { value: v.abs(), has_decimal: true, is_float: *is_float },
746        };
747
748        let forms = match self.currency_forms.get(currency) {
749            Some(forms) => forms,
750            // `else: return self.to_cardinal(val)` — bug 5. Note this returns
751            // before `is_negative` is ever consulted, and `val` is already
752            // abs()'d, so the sign is dropped.
753            None => {
754                return match &val {
755                    CurrencyValue::Int(v) => self.to_cardinal(v),
756                    CurrencyValue::Decimal { value: v, .. } => self.to_cardinal_decimal(v),
757                };
758            }
759        };
760
761        // decimal_val = Decimal(str(val)), with val already abs()'d.
762        let decimal_val = match &val {
763            CurrencyValue::Int(v) => BigDecimal::from(v.clone()),
764            CurrencyValue::Decimal { value: v, .. } => v.clone(),
765        };
766        // has_fractional_cents = (decimal_val * 100) % 1 != 0. The 100 is
767        // hard-coded in the source, not CURRENCY_PRECISION (bug 6). decimal_val
768        // is non-negative here, so Decimal's truncating `%` needs no floor fix.
769        let scaled = &decimal_val * BigDecimal::from(100);
770        let has_fractional_cents = !(&scaled - scaled.with_scale(0)).is_zero();
771
772        let (whole, cents_value) = match &val {
773            // `if isinstance(val, float):`
774            CurrencyValue::Decimal { value: _, .. } => {
775                if has_fractional_cents {
776                    // whole = int(decimal_val)
777                    // cents_value = decimal_val * 100 - (whole * 100)
778                    let whole = decimal_val.with_scale(0).as_bigint_and_exponent().0;
779                    let cv = &scaled - BigDecimal::from(&whole * BigInt::from(100));
780                    (whole, SqCents::Dec(cv))
781                } else {
782                    // whole = int(val)
783                    // cents_value = int(round((val - whole) * 100))
784                    let whole = decimal_val.with_scale(0).as_bigint_and_exponent().0;
785                    // has_fractional_cents is false, so val*100 is an integer
786                    // and (val - whole)*100 is exactly one too: round() can see
787                    // no tie and truncation is the same answer.
788                    let cv = (&decimal_val - BigDecimal::from(whole.clone())) * BigDecimal::from(100);
789                    (whole, SqCents::Int(cv.with_scale(0).as_bigint_and_exponent().0))
790                }
791            }
792            // `else: whole = int(val); cents_value = 0` — a true int never
793            // shows cents, which is why `1` is "një euro" and `1.0` is too,
794            // but for a different reason (its cents_value rounds to 0).
795            CurrencyValue::Int(v) => (v.clone(), SqCents::Int(BigInt::zero())),
796        };
797
798        // `if whole > 0:` — so 0.01 EUR is "një cent", with no "zero euro".
799        if whole.is_positive() {
800            result.push(self.to_cardinal(&whole)?);
801            result.push(self.pluralize(&whole, &forms.unit)?);
802        }
803
804        // `if cents_value > 0 and cents:`
805        let cents_positive = match &cents_value {
806            SqCents::Int(v) => v.is_positive(),
807            SqCents::Dec(v) => v.is_positive(),
808        };
809        // Python branches on `isinstance(val, float)`: a *float* renders its
810        // cents, while a Decimal (what a string input becomes through
811        // str_to_number) takes the else-arm — `whole = int(val);
812        // cents_value = 0` — so `to_currency("1.5")` is just "një lek".
813        // CurrencyValue carries the origin as `is_float`, so both arms are
814        // representable: a non-float Decimal simply never shows cents here.
815        let from_float = matches!(&val, CurrencyValue::Decimal { is_float: true, .. });
816        if cents_positive && cents && !from_float {
817            // Python's else-arm: whole = int(val); cents_value = 0 — the
818            // cents segment vanishes for Decimal input.
819        } else if cents_positive && cents {
820            if whole.is_positive() {
821                // Python collapses what it has so far into one string and
822                // glues the separator onto its tail, so the separator lands
823                // with no space before it and exactly one after.
824                let main_part = result.join(" ");
825                result = vec![format!("{}{}", main_part, separator)];
826            }
827            match &cents_value {
828                SqCents::Dec(v) => {
829                    // self.to_cardinal_float(float(cents_value)). The float()
830                    // cast is load-bearing and not just a type change: it is
831                    // what re-derives `precision` from the *repr* of the
832                    // double rather than from the Decimal's own scale, which
833                    // is both wider (Decimal("0.500") -> 0.5, precision 1) and
834                    // sometimes longer than the shortest round-trip form.
835                    result.push(self.to_cardinal_float(v.to_f64().unwrap_or(f64::NAN))?);
836                    // self.pluralize(int(cents_value), ...) — the *truncated*
837                    // cents, so 0.5 cents pluralizes on 0 and takes the plural.
838                    let int_cents = v.with_scale(0).as_bigint_and_exponent().0;
839                    result.push(self.pluralize(&int_cents, &forms.subunit)?);
840                }
841                SqCents::Int(v) => {
842                    result.push(self.to_cardinal(v)?);
843                    result.push(self.pluralize(v, &forms.subunit)?);
844                }
845            }
846        }
847
848        // `result.insert(0, self.negword.strip())` — a separate join element,
849        // so it is "minus dymbëdhjetë euro, ...".
850        if is_negative {
851            result.insert(0, self.negword().trim().to_string());
852        }
853
854        // to_currency(0, "EUR") == "" — nothing was ever appended.
855        Ok(result.join(" "))
856    }
857
858    // `to_cheque` is intentionally not overridden: SQ inherits
859    // Num2Word_Base.to_cheque, which `default_to_cheque` already ports. It
860    // reaches `currency_forms` (raising NotImplementedError for KWD/BHD/INR/
861    // CNY), `currency_precision` (100) and `money_verbose` (-> to_cardinal),
862    // all of which are correct for SQ above.
863}