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 ¢s_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 ¢s_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}