Skip to main content

num2words2_core/
lang_sk.rs

1//! Port of `lang_SK.py` (Slovak). Registry key `"sk"` → `Num2Word_SK`
2//! (verified in `num2words2/__init__.py`: `"sk": lang_SK.Num2Word_SK()`).
3//!
4//! Shape: **self-contained**. `Num2Word_SK` subclasses `Num2Word_Base` but
5//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
6//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
7//! outright and drives `_int2word` over 3-digit chunks. `cards`/`maxval`/
8//! `merge` therefore stay at their trait defaults, and there is **no overflow
9//! check**: the only ceiling is the `THOUSANDS` table, which raises `KeyError`
10//! rather than `OverflowError` (see bug 4 below).
11//!
12//! Inherited from `Num2Word_Base` (SK does not override either, so the trait
13//! defaults are exactly right):
14//!   * `to_ordinal_num(value) -> value` → default `Ok(value.to_string())`.
15//!     Corpus confirms the raw form, negatives included: `-1` → `"-1"`.
16//!   * `to_year(value, **kwargs) -> self.to_cardinal(value)` → default
17//!     delegates through `&self` and picks up the `to_cardinal` override
18//!     below. Slovak has no era/century handling, so years are plain
19//!     cardinals: `1999` → "tisíc deväťsto deväťdesiat deväť", `-44` →
20//!     "mínus štyridsať štyri".
21//!
22//! `setup()` sets `negword = "mínus"` and `pointword = "celých"`.
23//!
24//! # Faithfully reproduced Python bugs
25//!
26//! This is a port, not a rewrite. Every item below is wrong-looking Slovak but
27//! is exactly what Python emits, confirmed against the frozen corpus:
28//!
29//! 1. **`to_ordinal` glues a bare "ý" onto the cardinal** for any number
30//!    outside its 29-entry lookup table. The comment in Python calls this "a
31//!    simplified implementation"; the results are not Slovak words:
32//!    `to_ordinal(0)` == "nulaý", `to_ordinal(25)` == "dvadsať päťý",
33//!    `to_ordinal(200)` == "dvestoý", `to_ordinal(10**6)` == "milióný",
34//!    `to_ordinal(10**9)` == "miliardaý". The suffix is appended to the whole
35//!    string, so a multi-word cardinal only inflects its *last* word.
36//! 2. **Negative ordinals produce "mínus …ý"** rather than raising. Unlike
37//!    `lang_PL`, SK's `to_cardinal` strips the sign *before* the digits reach
38//!    `splitbyx`/`get_digits`, so no `ValueError` is ever triggered:
39//!    `to_ordinal(-1)` == "mínus jedený". Base's `verify_ordinal` (which would
40//!    reject negatives with a `TypeError`) is never called.
41//! 3. **Thousands are written detached and mis-pluralized.** `_int2word`
42//!    joins chunk words with spaces, so 2000 == "dve tisíc" (idiomatic Slovak
43//!    is "dvetisíc"), and `pluralize` keys off the whole 3-digit chunk rather
44//!    than its last digit, so 123 selects form 2: `123456789` ==
45//!    "sto dvadsať tri **miliónov** …" where Slovak wants "milióny".
46//! 4. **`THOUSANDS` stops at key 10 (10^30).** A chunk index of 11 or more —
47//!    i.e. any value >= 10^33 — is a `KeyError`, which is Slovak's de facto
48//!    (and rather abrupt) MAXVAL. Modelled by [`LangSk::thousands_at`].
49//! 5. **Typo in `THOUSANDS[10]`**: the plural form is "kvintillióny" with a
50//!    doubled `l`, while the singular "kvintilión" and genitive
51//!    "kvintiliónov" both have one. Kept verbatim.
52//! 6. **`to_currency`'s int path mis-pluralizes every count except 1.** It
53//!    picks `cr1[0]`/`cr1[1]` by hand instead of calling `self.pluralize`, so
54//!    0, 5+ and the teens all take the paucal (2-4) form where `pluralize`
55//!    would pick the genitive `cr1[2]`. See [`LangSk::to_currency`].
56//! 7. **`to_currency` has no gender agreement.** The count comes from
57//!    `to_cardinal`, which only ever emits the masculine "jeden"/"dva", so a
58//!    feminine unit reads wrong: `1 CZK` is "jeden koruna" (Slovak wants
59//!    "jedna koruna") and `2 CZK` is "dva koruny" (wants "dve koruny").
60//!    `_int2word`'s feminine forms are keyed off the `THOUSANDS` scale index
61//!    only (see [`ONES`]) and never off the currency, so nothing here can
62//!    reach them.
63//!
64//! # The currency surface
65//!
66//! `Num2Word_SK` declares its own `CURRENCY_FORMS` in the class body and
67//! subclasses `Num2Word_Base`, **not** `Num2Word_EUR` — so the shared-class-dict
68//! mutation that `Num2Word_EN.__init__` performs on `Num2Word_EUR.CURRENCY_FORMS`
69//! (see PORTING_CURRENCY.md) cannot reach it: Base's own `CURRENCY_FORMS` is a
70//! different, empty dict, and SK's class attribute shadows it outright. Here the
71//! literal source *is* what runs. Verified against the live interpreter — SK
72//! sees exactly three codes (CZK/EUR/USD) and nothing else, so every other code
73//! raises `NotImplementedError`, the corpus-covered
74//! GBP/JPY/KWD/BHD/INR/CNY/CHF included.
75//!
76//! `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are Base's empty dicts (SK
77//! adds neither), which settles several hooks by inheritance:
78//!   * `currency_adjective` — always absent, so `adjective=True` never prefixes
79//!     anything on either path. The trait default `None` is correct.
80//!   * `currency_precision` — `.get(code, 100)` is 100 for every code, so the
81//!     trait default 100 is correct. **SK has no 3-decimal and no 0-decimal
82//!     currency**: `default_to_currency`'s `divisor == 1` (JPY) and
83//!     `divisor == 1000` (KWD/BHD) branches are unreachable, because those
84//!     codes are absent from `CURRENCY_FORMS` and raise first.
85//!   * `money_verbose` / `cents_verbose` — both are `self.to_cardinal(n)`, which
86//!     the defaults already do through `&self`, so they pick up SK's
87//!     `to_cardinal` override.
88//!   * `cents_terse` — Base's zero-padded `"%0*d"`, mirrored by
89//!     `currency::default_cents_terse`. `cents=False` at 12.05 USD gives
90//!     "dvanásť dolárov, 05 centov".
91//!   * `to_cheque` — Base's, unoverridden. It reads `cr1[-1]` (the genitive
92//!     plural), calls `_money_verbose` for the whole part and upper-cases the
93//!     lot: `cheque:USD` of 1234.56 → "TISÍC DVESTO TRIDSAŤ ŠTYRI AND 56/100
94//!     DOLÁROV". `currency::default_to_cheque` matches it field for field.
95//!   * `cardinal_from_decimal` — left at the default per PORTING_CURRENCY.md
96//!     ("the float/Decimal cardinal path is a later phase"). This is the one
97//!     known gap, and unlike most languages SK's is **not** benign:
98//!
99//!     It backs `default_to_currency`'s fractional-cents branch, which Python
100//!     reaches as `self.to_cardinal(float(right))` — i.e. through SK's
101//!     `to_cardinal` *decimal* branch, not through `Num2Word_Base
102//!     .to_cardinal_float`, which is what the default routes to. The two
103//!     disagree whenever the fractional remainder has more than one digit,
104//!     because SK reads it as a **whole number** while Base spells it
105//!     **digit by digit**: `to_currency(3.14159, "USD")` is
106//!     "tri doláre, štrnásť celých **sto päťdesiat deväť** centy" in Python but
107//!     would be "...štrnásť celých **jeden päť deväť** centy" through the
108//!     default. Verified against the live interpreter. No corpus row reaches
109//!     it (every `arg` has at most two decimals, so `right` is always whole).
110//!     Closing it means porting SK's `to_cardinal` decimal branch — the later
111//!     float phase — not patching this hook.
112//!
113//! # Error variants
114//!
115//! `KeyError` (bug 4) is reachable via [`key_error`]. It is a Python crash
116//! rather than a deliberate raise, but the exception *type* is observable and
117//! callers may catch it, so parity means reproducing it rather than tidying it
118//! into an `OverflowError`. [`value_error`] models `int()` on a non-numeric
119//! token: on the integer path the sign is stripped before the digit helpers, so
120//! it never fires there, but the float path ([`LangSk::to_cardinal_float`])
121//! *does* reach it — a repr/str with no "." (scientific notation, `inf`, `nan`)
122//! or a fractional token carrying an `'e'` feeds `int()` a non-decimal token,
123//! exactly as Python's `to_cardinal` does.
124//!
125//! The currency surface adds two more, both routed through `&self` so they keep
126//! SK's semantics:
127//!   * `NotImplementedError` for an unknown code, raised by
128//!     `currency::default_to_currency`/`default_to_cheque` off
129//!     [`Lang::currency_forms`] returning `None`. Message verified byte for
130//!     byte: `Currency code "GBP" not implemented for "Num2Word_SK"`.
131//!   * `KeyError` again — bug 4 is reachable *through* `to_currency`, since the
132//!     int path calls `to_cardinal`: `to_currency(10**33, "EUR")` is
133//!     `KeyError: 11` in Python, not an OverflowError. `to_currency(10**30)` is
134//!     fine ("kvintilión eurá").
135
136use crate::base::{Lang, N2WError, Result};
137use crate::currency::{CurrencyForms, CurrencyValue};
138use crate::floatpath::FloatValue;
139use crate::strnum::{python_decimal_parse, ParsedNumber};
140use bigdecimal::BigDecimal;
141use num_bigint::BigInt;
142use num_traits::{FromPrimitive, One, Signed, Zero};
143use std::collections::HashMap;
144
145const ZERO: &str = "nula";
146const NEGWORD: &str = "mínus";
147const POINTWORD: &str = "celých";
148
149/// `ONES`: keys 1..=9, each `(masculine, feminine, scales_using_feminine)`.
150///
151/// Only key 2 carries a non-empty set: `{1, 3, 5, 7, 9}` are the chunk indices
152/// whose `THOUSANDS` noun is grammatically feminine (tisíc, miliarda,
153/// biliarda, triliarda, kvadriliarda), where Slovak wants "dve" over "dva".
154/// Index 0 is absent in Python and unreachable (guarded by `n1 > 0`).
155const ONES: [(&str, &str, &[usize]); 10] = [
156    ("", "", &[]), // absent in Python
157    ("jeden", "jeden", &[]),
158    ("dva", "dve", &[1, 3, 5, 7, 9]),
159    ("tri", "tri", &[]),
160    ("štyri", "štyri", &[]),
161    ("päť", "päť", &[]),
162    ("šesť", "šesť", &[]),
163    ("sedem", "sedem", &[]),
164    ("osem", "osem", &[]),
165    ("deväť", "deväť", &[]),
166];
167
168/// `TENS`: keys 0..=9 — the 10..=19 teens, keyed by the *units* digit.
169const TENS: [&str; 10] = [
170    "desať",
171    "jedenásť",
172    "dvanásť",
173    "trinásť",
174    "štrnásť",
175    "pätnásť",
176    "šestnásť",
177    "sedemnásť",
178    "osemnásť",
179    "devätnásť",
180];
181
182/// `TWENTIES`: keys 2..=9. Indices 0/1 are absent in Python (guarded `n2 > 1`).
183const TWENTIES: [&str; 10] = [
184    "",
185    "",
186    "dvadsať",
187    "tridsať",
188    "štyridsať",
189    "päťdesiat",
190    "šesťdesiat",
191    "sedemdesiat",
192    "osemdesiat",
193    "deväťdesiat",
194];
195
196/// `HUNDREDS`: keys 1..=9. Index 0 is absent in Python (guarded `n3 > 0`).
197const HUNDREDS: [&str; 10] = [
198    "",
199    "sto",
200    "dvesto",
201    "tristo",
202    "štyristo",
203    "päťsto",
204    "šesťsto",
205    "sedemsto",
206    "osemsto",
207    "deväťsto",
208];
209
210/// `THOUSANDS`: chunk index → the three plural forms (1 / 2..4 / 5+).
211///
212/// Keys 1..=10, i.e. 1000^1 (10^3) through 1000^10 (10^30). Index 0 is a
213/// placeholder — Python has no key 0 and never looks one up (guarded `i > 0`).
214/// A chunk index of 11 or more is a `KeyError`; see bug 4 in the module docs.
215///
216/// Note `THOUSANDS[10].1` == "kvintillióny" — a doubled `l` that the singular
217/// and genitive forms lack. Python's typo, preserved (bug 5).
218const THOUSANDS: [[&str; 3]; 11] = [
219    ["", "", ""], // absent in Python
220    ["tisíc", "tisíc", "tisíc"],                            // 10^3
221    ["milión", "milióny", "miliónov"],                      // 10^6
222    ["miliarda", "miliardy", "miliárd"],                    // 10^9
223    ["bilión", "bilióny", "biliónov"],                      // 10^12
224    ["biliarda", "biliardy", "biliárd"],                    // 10^15
225    ["trilión", "trilióny", "triliónov"],                   // 10^18
226    ["triliarda", "triliardy", "triliárd"],                 // 10^21
227    ["kvadrilión", "kvadrilióny", "kvadriliónov"],          // 10^24
228    ["kvadriliarda", "kvadriliardy", "kvadriliárd"],        // 10^27
229    ["kvintilión", "kvintillióny", "kvintiliónov"],         // 10^30 — sic
230];
231
232/// The `ordinals` dict local to `Num2Word_SK.to_ordinal`.
233///
234/// 29 entries: 1..=20, the round tens 30..=90, 100 and 1000. Everything else
235/// falls through to the "cardinal + ý" path (bug 1). Keys are `u32` because
236/// the table's own domain is tiny; the *input* stays `BigInt` and is compared
237/// by value, so a huge or negative argument simply misses every entry — which
238/// is exactly Python's `num in ordinals`.
239const ORDINALS: [(u32, &str); 29] = [
240    (1, "prvý"),
241    (2, "druhý"),
242    (3, "tretí"),
243    (4, "štvrtý"),
244    (5, "piaty"),
245    (6, "šiesty"),
246    (7, "siedmy"),
247    (8, "ôsmy"),
248    (9, "deviaty"),
249    (10, "desiaty"),
250    (11, "jedenásty"),
251    (12, "dvanásty"),
252    (13, "trinásty"),
253    (14, "štrnásty"),
254    (15, "pätnásty"),
255    (16, "šestnásty"),
256    (17, "sedemnásty"),
257    (18, "osemnásty"),
258    (19, "devätnásty"),
259    (20, "dvadsiaty"),
260    (30, "tridsiaty"),
261    (40, "štyridsiaty"),
262    (50, "päťdesiaty"),
263    (60, "šesťdesiaty"),
264    (70, "sedemdesiaty"),
265    (80, "osemdesiaty"),
266    (90, "deväťdesiaty"),
267    (100, "stý"),
268    (1000, "tisíci"),
269];
270
271// --- Python exception encoding -------------------------------------------
272//
273// Encode the Python exception name in the message so a later phase can remap
274// without re-deriving the semantics.
275
276/// Python `KeyError` — the missing `THOUSANDS` entry past 10^30 (bug 4).
277fn key_error(key: String) -> N2WError {
278    N2WError::Key(key)
279}
280
281/// Python `ValueError` from `int()` on a non-numeric token. On the integer
282/// path it is unreachable (the sign never reaches the digit helpers), but the
283/// float path reaches it: a repr/str with no "." (scientific notation, `inf`,
284/// `nan`) or a fractional token carrying an `'e'` feeds `int()` a non-decimal
285/// token, exactly as Python's `Num2Word_SK.to_cardinal` does. Also kept so
286/// [`splitbyx`]/[`get_digits`] mirror `utils.py` rather than assuming clean
287/// input.
288fn value_error(msg: String) -> N2WError {
289    N2WError::Value(msg)
290}
291
292/// Python's `int(s)` for the digit-string shapes this module produces.
293/// `int("024")` == 24; `int("-")` and `int("")` raise `ValueError`.
294fn parse_int(s: &str) -> Result<BigInt> {
295    BigInt::parse_bytes(s.as_bytes(), 10)
296        .ok_or_else(|| value_error(format!("invalid literal for int() with base 10: '{}'", s)))
297}
298
299/// Port of `utils.splitbyx(n, x)` with `format_int=True`.
300///
301/// Splits a digit string into chunks of `x` from the right, so the *first*
302/// chunk is the short one: `"2024"` → `[2, 24]`, `"1000000"` → `[1, 0, 0]`.
303/// Indexes by `chars()`, never bytes — the caller only ever passes ASCII
304/// digits here, but the shared helper's contract is character-based.
305fn splitbyx(n: &str, x: usize) -> Result<Vec<BigInt>> {
306    let chars: Vec<char> = n.chars().collect();
307    let length = chars.len();
308    let slice = |i: usize, j: usize| -> String { chars[i..j.min(length)].iter().collect() };
309
310    let mut out: Vec<BigInt> = Vec::new();
311    if length > x {
312        let start = length % x;
313        if start > 0 {
314            out.push(parse_int(&slice(0, start))?);
315        }
316        let mut i = start;
317        while i < length {
318            out.push(parse_int(&slice(i, i + x))?);
319            i += x;
320        }
321    } else {
322        out.push(parse_int(n)?);
323    }
324    Ok(out)
325}
326
327/// Python's `"%03d" % n`: field width 3 **including** any sign.
328/// `1` → `"001"`, `24` → `"024"`, `123` → `"123"`, `1234` → `"1234"`.
329fn fmt_03(n: &BigInt) -> String {
330    let mag = n.abs().to_string();
331    if n.is_negative() {
332        let pad = 3usize.saturating_sub(1 + mag.len());
333        format!("-{}{}", "0".repeat(pad), mag)
334    } else {
335        let pad = 3usize.saturating_sub(mag.len());
336        format!("{}{}", "0".repeat(pad), mag)
337    }
338}
339
340/// Port of `utils.get_digits(n)`:
341/// `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` → `[n1, n2, n3]`
342/// (units, tens, hundreds).
343///
344/// Callers only ever pass a chunk in 0..=999, so the `[-3:]` slice is the
345/// whole formatted string and no digit is ever dropped.
346fn get_digits(n: &BigInt) -> Result<[usize; 3]> {
347    let s = fmt_03(n);
348    let chars: Vec<char> = s.chars().collect();
349    // fmt_03 always yields >= 3 chars, so the [-3:] slice is total.
350    let tail = &chars[chars.len() - 3..];
351    let mut a = [0usize; 3];
352    for (k, c) in tail.iter().rev().enumerate() {
353        a[k] = c.to_digit(10).ok_or_else(|| {
354            value_error(format!("invalid literal for int() with base 10: '{}'", c))
355        })? as usize;
356    }
357    Ok(a)
358}
359
360/// The form index `Num2Word_SK.pluralize` selects:
361///
362/// ```python
363/// if n == 1:      form = 0
364/// elif 0 < n < 5: form = 1
365/// else:           form = 2
366/// ```
367///
368/// Unlike most Slavic siblings (`lang_CS` keys off `n % 10` / `n % 100`), SK
369/// tests the number *whole*, which is bug 3: a 3-digit chunk of 123 is not
370/// `< 5`, so it takes form 2 and 123 millions reads "miliónov" where Slovak
371/// wants "milióny".
372///
373/// `n` is non-negative on every path that reaches here — `_int2word` passes a
374/// chunk in 0..=999, and the currency path passes unit/subunit counts that
375/// `parse_currency_parts`/`abs()` have already made positive — so the
376/// `0 < n` guard only ever excludes `n == 0` (which takes form 2).
377///
378/// Note: [`LangSk::pluralize`] spells the same rule out inline against a fixed
379/// 3-tuple, the shape `_int2word` needs for `THOUSANDS[i]`; that method is
380/// verified against the integer corpus and is deliberately left untouched here.
381/// The two must stay in agreement.
382fn plural_form_index(n: &BigInt) -> usize {
383    if n.is_one() {
384        0
385    } else if n.is_positive() && n < &BigInt::from(5) {
386        1
387    } else {
388        2
389    }
390}
391
392/// `Num2Word_SK.CURRENCY_FORMS`, transcribed from the class body.
393///
394/// Three codes, no more: SK descends from `Num2Word_Base`, so it inherits none
395/// of the ~24 extra codes `Num2Word_EN.__init__` writes into
396/// `Num2Word_EUR.CURRENCY_FORMS`. Confirmed against the live interpreter.
397///
398/// All six tuples carry three forms — nominative singular / paucal (2-4) /
399/// genitive plural — and the arity is load-bearing: `pluralize` indexes 0/1/2,
400/// `to_cheque` takes `cr1[-1]`, and `to_currency`'s int path takes `cr1[1]`.
401fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
402    const CENTS: [&str; 3] = ["cent", "centy", "centov"];
403
404    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
405    m.insert(
406        "EUR",
407        CurrencyForms::new(&["euro", "eurá", "eur"], &CENTS),
408    );
409    m.insert(
410        "CZK",
411        CurrencyForms::new(
412            &["koruna", "koruny", "korún"],
413            &["halier", "haliere", "halierov"],
414        ),
415    );
416    m.insert(
417        "USD",
418        CurrencyForms::new(&["dolár", "doláre", "dolárov"], &CENTS),
419    );
420    m
421}
422
423/// Reproduce CPython's `repr(float)` for a finite-or-not `f64`.
424///
425/// `Num2Word_SK.to_cardinal` starts with `n = str(number)` and then *string-
426/// splits* on `.` — it never calls `float2tuple`, so the f64-artefact heuristic
427/// in `floatpath` is irrelevant here. What matters is that `str(number)` for a
428/// Python float is byte-identical to `repr(number)` (shortest round-trip), and
429/// the SK algorithm reads the characters of that string. So the port must
430/// rebuild that exact string.
431///
432/// CPython uses David Gay's shortest `dtoa` (mode 0): a digit string plus a
433/// decimal-point position `decpt`. It prints fixed notation iff
434/// `-4 < decpt <= 16` and scientific otherwise, appending `.0` in fixed
435/// notation when nothing follows the point (`Py_DTSF_ADD_DOT_0`) and padding the
436/// scientific exponent to at least two digits (`1e-05`, `1e+16`). Rust's `{:e}`
437/// emits the same shortest digit string with `decpt == exp + 1`, so the two
438/// reconstruct identically. Mirrors `lang_cs.rs`'s `python_repr_f64`.
439fn python_repr_f64(f: f64) -> String {
440    if f.is_nan() {
441        // repr(float('nan')) == 'nan', sign dropped. Feeds the no-'.' branch,
442        // where int('nan') raises ValueError — reproduced downstream.
443        return "nan".to_string();
444    }
445    if f.is_infinite() {
446        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
447    }
448
449    // `is_sign_negative` captures -0.0, whose repr is '-0.0'.
450    let neg = f.is_sign_negative();
451    let s = format!("{:e}", f.abs()); // e.g. "1.234e1", "5e-1", "0e0"
452    let (mantissa, exp_s) = match s.split_once('e') {
453        Some(parts) => parts,
454        None => return s, // unreachable for finite f64
455    };
456    let exp: i32 = exp_s.parse().unwrap_or(0);
457    let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
458    let ndigits = digits.len() as i32;
459    let decpt = exp + 1;
460
461    let body = if decpt <= -4 || decpt > 16 {
462        // Scientific: first digit, optional ".rest", then "e±NN".
463        let mut m = String::new();
464        m.push_str(&digits[..1]);
465        if ndigits > 1 {
466            m.push('.');
467            m.push_str(&digits[1..]);
468        }
469        let e = decpt - 1;
470        let (esign, eabs) = if e < 0 { ('-', -e) } else { ('+', e) };
471        format!("{}e{}{:02}", m, esign, eabs)
472    } else if decpt <= 0 {
473        // 0.00…digits
474        format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
475    } else if decpt >= ndigits {
476        // digits, trailing zeros, then the ADD_DOT_0 ".0".
477        format!("{}{}.0", digits, "0".repeat((decpt - ndigits) as usize))
478    } else {
479        // digits[:decpt] "." digits[decpt:]
480        let dp = decpt as usize;
481        format!("{}.{}", &digits[..dp], &digits[dp..])
482    };
483
484    if neg {
485        format!("-{}", body)
486    } else {
487        body
488    }
489}
490
491/// Reproduce CPython's `str(Decimal)` for the value carried on
492/// `FloatValue::Decimal`.
493///
494/// `Num2Word_SK.to_cardinal` sees `n = str(number)` for a `Decimal` too, and
495/// again string-splits it. `str(Decimal)` is `_pydecimal.__str__`'s
496/// to-scientific-string: it keeps the coefficient's trailing zeros (they live
497/// in the exponent, not the digits), so `Decimal('1.10')` prints `"1.10"`, and
498/// it switches to `E`-notation exactly when `exp > 0` or the adjusted exponent
499/// `< -6`. `BigDecimal::as_bigint_and_exponent` gives `(coefficient, scale)`
500/// with `value == coefficient * 10^-scale`, i.e. Python's `_exp == -scale` and
501/// `_int == str(abs(coefficient))`, so the same algorithm reconstructs it.
502/// Preserving trailing zeros is load-bearing: `"1.10"` must stay `"1.10"` (SK
503/// reads the fraction as the whole number `10` → "desať"), never `"1.1"`.
504/// Mirrors `lang_cs.rs`'s `python_str_decimal`.
505///
506/// Default context is non-engineering with `capitals == 1`, hence upper-case
507/// `E` and an unpadded `%+d` exponent (`1E-7`, not `1e-07`).
508fn python_str_decimal(bd: &BigDecimal) -> String {
509    let (coeff, scale) = bd.as_bigint_and_exponent();
510    let py_exp: i64 = -scale; // Decimal._exp
511    let neg = coeff.is_negative(); // BigInt drops the sign of a negative zero
512    let int_str = coeff.abs().to_string(); // Decimal._int
513    let ndigits = int_str.len() as i64;
514    let leftdigits = py_exp + ndigits;
515
516    // dotplace, non-engineering branch of _pydecimal.__str__.
517    let dotplace: i64 = if py_exp <= 0 && leftdigits > -6 {
518        leftdigits
519    } else {
520        1
521    };
522
523    let (intpart, fracpart) = if dotplace <= 0 {
524        (
525            "0".to_string(),
526            format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
527        )
528    } else if dotplace >= ndigits {
529        (
530            format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
531            String::new(),
532        )
533    } else {
534        let dp = dotplace as usize;
535        (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
536    };
537
538    let exp = if leftdigits == dotplace {
539        String::new()
540    } else {
541        // "%+d" — a sign but no zero-padding, unlike float repr.
542        format!("E{:+}", leftdigits - dotplace)
543    };
544
545    let sign = if neg { "-" } else { "" };
546    format!("{}{}{}{}", sign, intpart, fracpart, exp)
547}
548
549pub struct LangSk {
550    currency_forms: HashMap<&'static str, CurrencyForms>,
551}
552
553impl Default for LangSk {
554    fn default() -> Self {
555        Self::new()
556    }
557}
558
559impl LangSk {
560    pub fn new() -> Self {
561        LangSk {
562            // Built once here, never per call. `to_currency`/`to_cheque` only
563            // ever read this table, and rebuilding it on each call is what made
564            // an earlier revision of this port slower than the Python it
565            // replaces. `lib.rs` holds the instance in a `OnceLock`, so this
566            // runs at most once per process.
567            currency_forms: build_currency_forms(),
568        }
569    }
570
571    /// `THOUSANDS[i]`, raising `KeyError` past 10 — see bug 4.
572    fn thousands_at(&self, i: usize) -> Result<&'static [&'static str; 3]> {
573        if (1..=10).contains(&i) {
574            Ok(&THOUSANDS[i])
575        } else {
576            Err(key_error(i.to_string()))
577        }
578    }
579
580    /// Port of `Num2Word_SK.pluralize`.
581    ///
582    /// ```python
583    /// if n == 1:      form = 0
584    /// elif 0 < n < 5: form = 1
585    /// else:           form = 2
586    /// ```
587    /// `n` is the whole 3-digit chunk, not its last digit — which is why 123
588    /// millions comes out as "miliónov" (form 2) rather than "milióny"
589    /// (bug 3). `n` is never 0 here (`_int2word` skips zero chunks) and never
590    /// negative (`to_cardinal` strips the sign first).
591    fn pluralize(&self, n: &BigInt, forms: &[&'static str; 3]) -> &'static str {
592        let form = if n.is_one() {
593            0usize
594        } else if n.is_positive() && n < &BigInt::from(5) {
595            1
596        } else {
597            2
598        };
599        forms[form]
600    }
601
602    /// Port of `Num2Word_SK._int2word`. Only ever called with a non-negative
603    /// `n` — `to_cardinal` removes the sign before delegating here.
604    fn int2word(&self, n: &BigInt) -> Result<String> {
605        if n.is_zero() {
606            return Ok(ZERO.to_string());
607        }
608
609        let mut words: Vec<String> = Vec::new();
610        let chunks = splitbyx(&n.to_string(), 3)?;
611        let mut i = chunks.len();
612        for x in chunks.iter() {
613            i -= 1;
614
615            if x.is_zero() {
616                continue;
617            }
618
619            let [n1, n2, n3] = get_digits(x)?;
620
621            // Python builds a per-chunk list and joins it, then joins the
622            // chunk strings again with " ". Both joins use a single space, so
623            // the nesting is invisible in the output — but it is why an empty
624            // chunk would contribute an empty string rather than being
625            // skipped. Zero chunks `continue` above, so that never happens.
626            let mut word_chunk: Vec<&'static str> = Vec::new();
627
628            if n3 > 0 {
629                word_chunk.push(HUNDREDS[n3]);
630            }
631
632            if n2 > 1 {
633                word_chunk.push(TWENTIES[n2]);
634            }
635
636            if n2 == 1 {
637                word_chunk.push(TENS[n1]);
638            } else if n1 > 0 && !(i > 0 && x.is_one()) {
639                // A leading "jeden" is suppressed on scale chunks, so 1000 is
640                // "tisíc" and 10^6 is "milión" — not "jeden tisíc".
641                if n2 == 0 && n3 == 0 && ONES[n1].2.contains(&i) {
642                    word_chunk.push(ONES[n1].1);
643                } else {
644                    word_chunk.push(ONES[n1].0);
645                }
646            }
647
648            if i > 0 {
649                word_chunk.push(self.pluralize(x, self.thousands_at(i)?));
650            }
651
652            words.push(word_chunk.join(" "));
653        }
654
655        Ok(words.join(" "))
656    }
657}
658
659impl Lang for LangSk {
660
661    fn cardinal_float_entry(
662        &self,
663        value: &crate::floatpath::FloatValue,
664        precision_override: Option<u32>,
665    ) -> crate::base::Result<String> {
666        // Python's to_cardinal routes every float/Decimal through this
667        // language's own decimal grammar — 5.0 keeps its ".0" tail
668        // ("comma nulla"), unlike Base's whole-value integer route.
669        self.to_cardinal_float(value, precision_override)
670    }
671
672    /// `to_ordinal(float/Decimal)` — **not** the cardinal float grammar.
673    ///
674    /// ```text
675    /// try:
676    ///     num = int(number)
677    /// except (ValueError, TypeError):
678    ///     return str(number)
679    /// ```
680    ///
681    /// `int()` truncates toward zero, so `to_ordinal(2.5)` == "druhý",
682    /// `to_ordinal(-1.5)` == "mínus jedený" (int is -1), and `-0.0` -> 0 ->
683    /// "nulaý" with **no** minus. Exponent forms convert fine — `int(1e16)`
684    /// == 10**16, whose cardinal + "ý" is "desať biliárdý", and
685    /// `Decimal("1E+2")` -> 100 -> "stý" — unlike the cardinal path, where
686    /// the string algorithm raises ValueError on them. The `except` guard
687    /// never fires for a real float/Decimal (int() succeeds), so it is not
688    /// modelled here.
689    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
690        let num = match value {
691            // Truncation toward zero; every whole f64 is exactly
692            // representable, and NaN/±inf floats never reach this entry
693            // (the dispatcher keeps them on the Python side).
694            FloatValue::Float { value, .. } => {
695                BigInt::from_f64(value.trunc()).unwrap_or_else(BigInt::zero)
696            }
697            // `int(Decimal)` — with_scale(0) truncates toward zero, same as
698            // `floatpath::float2tuple`'s `pre`.
699            FloatValue::Decimal { value, .. } => {
700                value.with_scale(0).as_bigint_and_exponent().0
701            }
702        };
703        self.to_ordinal(&num)
704    }
705
706    // to_ordinal_num / to_year float entries stay at the trait defaults:
707    // SK overrides neither method, so Base's to_ordinal_num echoes
708    // str(number) (the default hook) and Base's to_year routes through
709    // to_cardinal (the default hook lands in cardinal_float_entry above).
710
711    /// Base's `str_to_number` parses "Infinity" *successfully*; SK's
712    /// ValueError comes later, from `int("Infinity")` inside `to_cardinal`
713    /// (no "." in the string, so the else arm feeds the whole token to
714    /// int()). The Rust dispatcher hard-codes Base's OverflowError for
715    /// `ParsedNumber::Inf`, which SK never executes — so Inf parses punt to
716    /// the Python fallback, which reproduces every mode byte for byte. NaN
717    /// stays on the dispatcher path (its hard-coded ValueError matches
718    /// `int("NaN")` on the cardinal path). Same shape as `lang_as.rs`.
719    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
720        let parsed = python_decimal_parse(s)?;
721        Ok(parsed)
722    }
723
724    /// `Decimal('Infinity')` / `-Infinity` from a string arg. SK's failure mode
725    /// depends on `to` — verified live against the pure-Python oracle:
726    ///
727    /// * `to_cardinal` / `to_year` reach `int(str(number))` == `int("Infinity")`
728    ///   through the else arm (no "." in the string) → `ValueError`. `-Infinity`
729    ///   strips its sign first, so the message quotes `'Infinity'`.
730    /// * `to_ordinal` does `int(number)` == `int(Decimal('Infinity'))` first,
731    ///   whose `OverflowError` the `except (ValueError, TypeError)` does **not**
732    ///   catch → `OverflowError` (Base's default here).
733    /// * `to_ordinal_num` returns the `Decimal` object unchanged (untested, and
734    ///   not representable as a string) — left on Base's `OverflowError`.
735    ///
736    /// NaN is *not* overridden: Base's `nan_result` already yields `ValueError`,
737    /// which matches `int("NaN")` on the corpus-covered cardinal path.
738    fn inf_result(&self, _negative: bool, to: &str) -> Result<String> {
739        match to {
740            "cardinal" | "year" => Err(N2WError::Value(
741                "invalid literal for int() with base 10: 'Infinity'".into(),
742            )),
743            // ordinal (OverflowError) and the un-representable ordinal_num keep
744            // Base's OverflowError.
745            _ => Err(N2WError::Overflow(
746                "cannot convert Infinity to integer".into(),
747            )),
748        }
749    }
750    /// This language's own `to_currency(currency=...)` default,
751    /// read from the live Python signature. Only 44 of 156 use EUR.
752    fn default_currency(&self) -> &str {
753        "EUR"
754    }
755
756    /// This language's own `to_currency(separator=...)` default,
757    /// read from the live Python signature. Base's is ",", but only
758    /// 36 of 149 languages actually use it — most default to " " or a
759    /// conjunction, so inheriting Base's comma silently corrupts them.
760    fn default_separator(&self) -> &str {
761        ","
762    }
763
764    fn negword(&self) -> &str {
765        NEGWORD
766    }
767
768    fn pointword(&self) -> &str {
769        "celých"
770    }
771
772    /// Port of `Num2Word_SK.to_cardinal`, integer path only.
773    ///
774    /// Python stringifies the input and looks for `"."` after a `","` → `"."`
775    /// swap; `str(int)` contains neither, so integers always take the `else`
776    /// branch. The float branch (`pointword`, leading-zero padding) is out of
777    /// scope.
778    ///
779    /// Python's `int(str(value)[1:])` for the negative case is exactly
780    /// `value.abs()` — `BigInt` never stringifies as `"-0"`, so there is no
781    /// edge case where the two diverge.
782    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
783        if value.is_negative() {
784            let result = self.int2word(&value.abs())?;
785            Ok(format!("{} {}", NEGWORD, result))
786        } else {
787            self.int2word(value)
788        }
789    }
790
791    /// Port of `Num2Word_SK.to_ordinal`.
792    ///
793    /// The `try: int(number) except (ValueError, TypeError): return str(number)`
794    /// guard cannot fire for an integer argument and is not modelled.
795    ///
796    /// Table hit → the proper Slovak ordinal. Table miss → cardinal + "ý",
797    /// which is what Python calls "a simplified implementation" and what the
798    /// corpus pins: "nulaý", "dvadsať päťý", "milióný", "mínus jedený"
799    /// (bugs 1 and 2).
800    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
801        for (k, word) in ORDINALS.iter() {
802            if value == &BigInt::from(*k) {
803                return Ok(word.to_string());
804            }
805        }
806        let cardinal = self.to_cardinal(value)?;
807        Ok(format!("{}ý", cardinal))
808    }
809
810    /// Port of the non-integer path of `Num2Word_SK.to_cardinal`.
811    ///
812    /// SK overrides `to_cardinal` outright and handles floats/Decimals **inline
813    /// off the string form** — `n = str(number)` — never through
814    /// `Num2Word_Base.to_cardinal_float` and never through `float2tuple`. So this
815    /// override does NOT call the shared `floatpath` code: it rebuilds
816    /// `n = str(number)` exactly (`python_repr_f64` for a float,
817    /// `python_str_decimal` for a Decimal) and runs the Python algorithm:
818    ///
819    /// ```python
820    /// n = str(number).replace(",", ".")
821    /// if "." in n:
822    ///     is_negative = n.startswith("-")
823    ///     abs_n = n[1:] if is_negative else n
824    ///     left, right = abs_n.split(".")
825    ///     leading_zero_count = len(right) - len(right.lstrip("0"))
826    ///     decimal_part = (ZERO[0] + " ") * leading_zero_count + self._int2word(int(right))
827    ///     result = "%s %s %s" % (self._int2word(int(left)), self.pointword, decimal_part)
828    ///     if is_negative: result = self.negword + " " + result
829    ///     return result
830    /// else:  # no "." — scientific/inf/nan, or an integral Decimal str
831    ///     is_negative = n.startswith("-")
832    ///     if is_negative: return self.negword + " " + self._int2word(int(n[1:]))
833    ///     else:           return self._int2word(int(n))
834    /// ```
835    ///
836    /// Consequences reproduced verbatim:
837    ///   * **The f64-artefact trap does not apply.** SK reads the shortest-
838    ///     round-trip repr, not `abs(value-pre)*10**precision`: `2.675` reads
839    ///     "675" straight off `"2.675"` → "dva celých šesťsto sedemdesiat päť",
840    ///     `1.005` reads "005" off `"1.005"` → "jeden celých nula nula päť".
841    ///   * **The fraction is spelled as a whole number, not digit by digit.**
842    ///     `int(right)` runs through `_int2word`, so `Decimal("1.10")` →
843    ///     "jeden celých desať" (ten), not "…jeden nula". Each leading zero of
844    ///     `right` prepends a bare "nula "; SK does **not** rstrip trailing zeros
845    ///     (unlike CS), so a float `1.0` (repr "1.0") → "jeden celých nula nula"
846    ///     (one leading-zero "nula" + `int2word(0)` "nula") and `Decimal("1.00")`
847    ///     → "jeden celých nula nula nula".
848    ///   * `precision` / `precision_override` are **ignored**. SK's Python
849    ///     `to_cardinal` takes no `precision` kwarg and never reads
850    ///     `self.precision`, so the `precision=` override the dispatcher stashes
851    ///     is dead for SK. The parameter is accepted and dropped.
852    ///   * A repr/str with **no "."** (scientific notation, `inf`, `nan`) feeds a
853    ///     non-decimal token to `int()`, which raises **ValueError** via
854    ///     [`parse_int`]. An integral Decimal like `Decimal('5')` prints `"5"`
855    ///     and the else arm returns "päť"; `Decimal('-3')` returns "mínus tri".
856    ///   * A "." repr that also carries an `'e'` (`1.5e-05` → `"1.5e-05"`) enters
857    ///     the decimal arm and hits `int("5e-05")` → ValueError, same as Python.
858    ///
859    /// negword is used **unstripped** (`self.negword + " " + result`), exactly as
860    /// the Python source; "mínus" has no surrounding whitespace so the bytes
861    /// match. `is_negative` keys off the reconstructed string's leading '-', so a
862    /// float `-0.0` (repr "-0.0") reads "mínus …" just as Python does.
863    fn to_cardinal_float(
864        &self,
865        value: &FloatValue,
866        _precision_override: Option<u32>,
867    ) -> Result<String> {
868        // n = str(number).replace(",", ".") — a float repr / Decimal str never
869        // carries a comma, so the replace is a no-op, kept for faithfulness.
870        let n_raw = match value {
871            FloatValue::Float { value, .. } => python_repr_f64(*value),
872            FloatValue::Decimal { value, .. } => python_str_decimal(value),
873        };
874        let n = n_raw.replace(',', ".");
875
876        if n.contains('.') {
877            let is_negative = n.starts_with('-');
878            // abs_n = n[1:] if is_negative else n. '-' is one ASCII byte, so
879            // slicing at 1 lands on a char boundary.
880            let abs_n: &str = if is_negative { &n[1..] } else { &n };
881
882            // left, right = abs_n.split(".")
883            let (left, right) = match abs_n.split_once('.') {
884                Some(pair) => pair,
885                // Unreachable: `n` contains '.', and it is never the leading char.
886                None => {
887                    return Err(value_error(format!(
888                        "invalid literal for int() with base 10: '{}'",
889                        abs_n
890                    )))
891                }
892            };
893
894            // leading_zero_count = len(right) - len(right.lstrip("0")) — the
895            // count of leading '0' chars. SK does not rstrip the fraction.
896            let leading_zero_count = right.chars().take_while(|c| *c == '0').count();
897
898            // decimal_part = (ZERO[0] + " ") * leading_zero_count
899            //                + self._int2word(int(right))
900            // int(right) is evaluated and _int2word run here, before int(left)
901            // below — matching Python's statement order.
902            let right_int = parse_int(right)?;
903            let right_word = self.int2word(&right_int)?;
904            let mut decimal_part = String::new();
905            for _ in 0..leading_zero_count {
906                decimal_part.push_str(ZERO);
907                decimal_part.push(' ');
908            }
909            decimal_part.push_str(&right_word);
910
911            // result = "%s %s %s" % (self._int2word(int(left)), pointword, decimal_part)
912            let left_int = parse_int(left)?;
913            let result = format!(
914                "{} {} {}",
915                self.int2word(&left_int)?,
916                POINTWORD,
917                decimal_part
918            );
919
920            if is_negative {
921                Ok(format!("{} {}", NEGWORD, result))
922            } else {
923                Ok(result)
924            }
925        } else {
926            // else arm: no "." — reachable only for scientific notation / inf /
927            // nan (all raise ValueError through parse_int) or an integral
928            // Decimal str like "5".
929            let is_negative = n.starts_with('-');
930            if is_negative {
931                let abs_n = &n[1..];
932                let m = parse_int(abs_n)?;
933                Ok(format!("{} {}", NEGWORD, self.int2word(&m)?))
934            } else {
935                let m = parse_int(&n)?;
936                self.int2word(&m)
937            }
938        }
939    }
940
941    // to_ordinal_num: SK does not override Num2Word_Base.to_ordinal_num, which
942    // returns the value unchanged → the trait default is correct.
943    //
944    // to_year: SK does not override Num2Word_Base.to_year, which delegates to
945    // to_cardinal → the trait default is correct.
946
947    // ---- currency -------------------------------------------------------
948    //
949    // SK supplies `CURRENCY_FORMS`, `pluralize` and an int-only `to_currency`.
950    // Everything else on the currency path is `Num2Word_Base`'s and is left at
951    // the trait default on purpose — see "The currency surface" in the module
952    // docs for why each one is already right.
953
954    fn lang_name(&self) -> &str {
955        "Num2Word_SK"
956    }
957
958    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
959        self.currency_forms.get(code)
960    }
961
962    /// Port of `Num2Word_SK.pluralize(n, forms)`.
963    ///
964    /// Python indexes the tuple directly, so a form list shorter than the
965    /// selected index raises IndexError. Every SK entry has three forms, so
966    /// that is unreachable — but it is mapped to `Index` rather than panicking
967    /// so the exception type survives if the table ever changes.
968    ///
969    /// Reached only from `Num2Word_Base.to_currency`'s float path; SK's own int
970    /// path pointedly does *not* call this (see [`LangSk::to_currency`]).
971    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
972        forms
973            .get(plural_form_index(n))
974            .cloned()
975            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
976    }
977
978    /// Port of `Num2Word_SK.to_currency`.
979    ///
980    /// SK intercepts **`isinstance(val, int)`** — a true Python `int`, never a
981    /// whole float — and hands everything else to `Num2Word_Base.to_currency`.
982    /// The split is why `currency:EUR` of `1` is "jeden euro" (no cents) while
983    /// `1.0` is "jeden euro, nula centov".
984    ///
985    /// # The int path's plural bug, reproduced (bug 6)
986    ///
987    /// Python picks the currency word by hand here instead of calling
988    /// `self.pluralize`:
989    ///
990    /// ```python
991    /// if abs_val == 1:
992    ///     currency_str = cr1[0]
993    /// else:
994    ///     currency_str = cr1[1] if len(cr1) > 1 else cr1[0]
995    /// ```
996    ///
997    /// So *every* count other than 1 takes `cr1[1]`, the paucal (2-4) form,
998    /// including 0, 5+ and the teens — where `pluralize` would correctly pick
999    /// the genitive `cr1[2]`. The corpus locks the wrong answers in:
1000    /// `currency:USD` of `0` is "nula doláre", of `100` is "sto doláre", of
1001    /// `1000000` is "milión doláre" — all should be "dolárov", and the float
1002    /// path one line below *does* say "dolárov" for the same magnitudes ("nula
1003    /// dolárov, päťdesiat centov" at 0.5). EUR shows it just as plainly:
1004    /// "nula eurá" (int 0) against "nula eur" (float 0.5). Do not "fix" this
1005    /// into a `pluralize` call.
1006    ///
1007    /// `abs(val)` is taken before the comparison, so `-1` is singular too:
1008    /// "mínus jeden euro".
1009    ///
1010    /// Bug 4 reaches through here: `to_cardinal` is called unguarded, so
1011    /// `to_currency(10**33, "EUR")` propagates `KeyError: 11` rather than
1012    /// raising OverflowError or rendering.
1013    fn to_currency(
1014        &self,
1015        val: &CurrencyValue,
1016        currency: &str,
1017        cents: bool,
1018        separator: Option<&str>,
1019        adjective: bool,
1020    ) -> Result<String> {
1021        if let CurrencyValue::Int(v) = val {
1022            // Python: `try: cr1, cr2 = self.CURRENCY_FORMS[currency]
1023            //          except (KeyError, AttributeError): return super()...`
1024            // The AttributeError arm is dead — `CURRENCY_FORMS` always exists —
1025            // so only a missing code falls through, and Base then hits the same
1026            // KeyError and raises NotImplementedError. Delegating is a longer
1027            // route to the same error, but it is the route Python takes.
1028            if let Some(forms) = self.currency_forms.get(currency) {
1029                // `self.negword`, used *unstripped* — unlike Base, which does
1030                // `"%s " % self.negword.strip()`. "mínus" has no surrounding
1031                // whitespace, so the two agree, but the shapes differ.
1032                let minus_str = if v.is_negative() { NEGWORD } else { "" };
1033                let abs_val = v.abs();
1034                let money_str = self.to_cardinal(&abs_val)?;
1035
1036                let currency_str = if abs_val.is_one() {
1037                    forms.unit.first().cloned()
1038                } else {
1039                    forms.unit.get(1).or_else(|| forms.unit.first()).cloned()
1040                }
1041                .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
1042
1043                // Python: ("%s %s %s" % (minus_str, money_str, currency_str))
1044                //             .strip()
1045                // The `.strip()` exists to eat the leading space that an empty
1046                // `minus_str` leaves behind; neither of the other two fields can
1047                // carry outer whitespace, so `.trim()` is equivalent.
1048                //
1049                // `cents`, `separator` and `adjective` are all ignored on this
1050                // path — Python drops them too. `adjective=True` is a no-op for
1051                // SK either way, since `CURRENCY_ADJECTIVES` is empty.
1052                return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
1053                    .trim()
1054                    .to_string());
1055            }
1056        }
1057
1058        // Floats and Decimals — plus ints whose code SK does not know — go to
1059        // `Num2Word_Base.to_currency`, which *does* use `pluralize`.
1060        crate::currency::default_to_currency(
1061            self,
1062            val,
1063            currency,
1064            cents,
1065            separator.unwrap_or(self.default_separator()),
1066            adjective,
1067        )
1068    }
1069}