Skip to main content

num2words2_core/
lang_sr_latn.rs

1//! Port of `lang_SR_LATN.py` (Serbian, Gaj's Latin alphabet).
2//!
3//! Registry check: `CONVERTER_CLASSES["sr_Latn"]` is
4//! `lang_SR_LATN.Num2Word_SR_LATN()` (`__init__.py:316`), so the key really
5//! does resolve to the class named in the brief.
6//!
7//! Shape: **self-contained**. `Num2Word_SR_LATN` subclasses `Num2Word_SR`,
8//! which subclasses `Num2Word_Base` but defines no `high_numwords` /
9//! `mid_numwords` / `low_numwords`. Python therefore never builds
10//! `self.cards` and never sets `MAXVAL`, so `cards`/`maxval`/`merge` stay at
11//! their trait defaults here and there is **no overflow check**. The only
12//! ceiling is the `SCALE` table (10**33 and up raises `KeyError` — see
13//! [`scale`]).
14//!
15//! `Num2Word_SR_LATN` itself is a pure transliteration wrapper: every method
16//! calls `super()` and pipes the Cyrillic result through `cyrl_to_latn`. All
17//! the arithmetic lives in `lang_SR.py`. This port keeps that structure —
18//! tables stay in Cyrillic and transliteration happens at the boundary —
19//! because the layering is load-bearing for `to_ordinal` (see below).
20//!
21//! # Inheritance chain, resolved
22//!
23//! | method          | defined in       | behaviour                                    |
24//! |-----------------|------------------|----------------------------------------------|
25//! | `to_cardinal`   | SR_LATN → SR     | `cyrl_to_latn(_int2word(int(n), False))`     |
26//! | `to_ordinal`    | SR_LATN → SR     | dict lookup, else cardinal + "и"             |
27//! | `to_ordinal_num`| SR_LATN → *Base* | `cyrl_to_latn(value)` → **AttributeError**   |
28//! | `to_year`       | SR_LATN → *Base* | `cyrl_to_latn(self.to_cardinal(value))`      |
29//!
30//! # Faithfully reproduced Python bugs
31//!
32//! This is a port, not a rewrite. All of the following are verified against
33//! the frozen corpus (`bench/corpus.jsonl`, `"lang": "sr_Latn"`):
34//!
35//! 1. **`to_ordinal_num` raises `AttributeError` for every input.** `SR` and
36//!    `SR_LATN` both fail to override it, so it lands on
37//!    `Num2Word_Base.to_ordinal_num`, which returns `value` — an `int`, not a
38//!    `str`. `SR_LATN.to_ordinal_num` then hands that int to `cyrl_to_latn`,
39//!    which immediately calls `s.replace(...)` on it:
40//!    `AttributeError: 'int' object has no attribute 'replace'`. **Every**
41//!    `ordinal_num` row in the corpus is `{"ok": false, "err":
42//!    "AttributeError"}` — there are no successful ones. See
43//!    [`attribute_error`] for how the variant is encoded.
44//! 2. **`to_ordinal` just glues "и" onto the cardinal** for anything outside
45//!    its small lookup dict (Python's own comment: "This is a simplified
46//!    implementation"). This produces non-words throughout, and they are the
47//!    expected output: `to_ordinal(200)` == "dvestai", `to_ordinal(2000)` ==
48//!    "dve hiljadei", `to_ordinal(42)` == "četrdeset dvai",
49//!    `to_ordinal(0)` == "nulai", `to_ordinal(10**9)` == "milijardai".
50//! 3. **Negative ordinals are cheerfully produced**, unlike most languages
51//!    which raise: `to_ordinal(-1)` == "minus jedani". `verify_ordinal` is
52//!    never called anywhere in this chain.
53//! 4. **`_int2word` drops `feminine` when recursing for negatives**
54//!    (`self._int2word(abs(number))` omits the argument, so it silently
55//!    resets to `False`). Out of scope for the four modes here — `to_cardinal`
56//!    only ever passes `feminine=False` — but reproduced anyway in
57//!    [`int2word`] so the bug is preserved if the parameter is ever threaded
58//!    through.
59//! 5. **`to_ordinal` transliterates twice.** `Num2Word_SR.to_ordinal` calls
60//!    `self.to_cardinal(num)`, which dynamically dispatches to the *SR_LATN*
61//!    override — so the cardinal is already Latin. It appends the Cyrillic
62//!    "и", and the `SR_LATN.to_ordinal` wrapper then re-runs `cyrl_to_latn`
63//!    over the whole string. The second pass is a no-op on the Latin part
64//!    (no replacement value contains a Cyrillic codepoint, so nothing
65//!    cascades) and converts the trailing "и" → "i". Mirrored exactly in
66//!    [`LangSrLatn::to_ordinal`] rather than short-circuited.
67//! 6. **`to_currency` ignores the currency code entirely for `int` input.**
68//!    `Num2Word_SR.to_currency` intercepts `isinstance(val, int)` before
69//!    delegating to Base and hardcodes "динар"/"динара", so *every* code —
70//!    including ones with no `CURRENCY_FORMS` entry — renders as dinars and
71//!    nothing raises. `cents`, `separator` and `adjective` are dropped on that
72//!    path too. The corpus pins all of it: `currency:JPY 100` → "sto dinara",
73//!    `currency:CHF 1` → "jedan dinar". Only the *float* path reaches
74//!    `Num2Word_Base.to_currency` and can raise `NotImplementedError`, which is
75//!    why every code has successful int rows and NotImplementedError float rows.
76//! 7. **`to_cheque` prints a stringified `bool` where the currency name
77//!    belongs.** See [`build_currency_forms`] — this is the reason the forms
78//!    table carries a fourth `"True"`/`"False"` element.
79//!
80//! # Not a bug, but load-bearing: the four-element currency tuples
81//!
82//! `Num2Word_SR.CURRENCY_FORMS` is the only table in the library whose form
83//! tuples end in a `bool` rather than a string. It changes the behaviour of two
84//! *inherited* methods it was never designed for; [`build_currency_forms`]
85//! documents how the arity is preserved here.
86//!
87//! # A note on `cyrl_to_latn`
88//!
89//! The Python comment claims two-character mappings "must come BEFORE
90//! single-character entries so they win during the longest-match scan". This
91//! is misleading: every *key* is a single codepoint (Љ is U+0409, not Л+Ј),
92//! so there is no longest-match scan and the order is irrelevant. The
93//! "digraphs" are digraphs on the *output* side ("Lj"). A plain per-char map
94//! is therefore exactly equivalent — see [`cyrl_to_latn`].
95
96use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
97use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
98use crate::floatpath::FloatValue;
99use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
100use num_bigint::BigInt;
101use num_integer::Integer;
102use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
103use std::collections::HashMap;
104
105const ZERO: &str = "нула";
106/// `setup()` sets `self.negword = "минус"` and `self.pointword = "запета"`
107/// (the latter is transliterated to "zapeta" and used by the float path —
108/// see [`LangSrLatn::to_cardinal_float`]).
109const NEGWORD: &str = "минус";
110
111/// `ONES`, keys 1..=9, `(masculine, feminine)`. Index 0 is absent in Python
112/// (guarded by `digit_right > 0`).
113const ONES: [(&str, &str); 10] = [
114    ("", ""), // absent in Python
115    ("један", "једна"),
116    ("два", "две"),
117    ("три", "три"),
118    ("четири", "четири"),
119    ("пет", "пет"),
120    ("шест", "шест"),
121    ("седам", "седам"),
122    ("осам", "осам"),
123    ("девет", "девет"),
124];
125
126/// `TENS`, keys 0..=9 — the teens, reached when `digit_mid == 1`.
127const TENS: [&str; 10] = [
128    "десет",
129    "једанаест",
130    "дванаест",
131    "тринаест",
132    "четрнаест",
133    "петнаест",
134    "шеснаест",
135    "седамнаест",
136    "осамнаест",
137    "деветнаест",
138];
139
140/// `TWENTIES`, keys 2..=9. Indices 0 and 1 are absent in Python (guarded by
141/// `digit_mid > 1`).
142const TWENTIES: [&str; 10] = [
143    "", // absent in Python
144    "", // absent in Python
145    "двадесет",
146    "тридесет",
147    "четрдесет",
148    "педесет",
149    "шездесет",
150    "седамдесет",
151    "осамдесет",
152    "деведесет",
153];
154
155/// `HUNDREDS`, keys 1..=9. Index 0 is absent in Python (guarded by
156/// `digit_left > 0`).
157const HUNDREDS: [&str; 10] = [
158    "", // absent in Python
159    "сто",
160    "двеста",
161    "триста",
162    "четиристо",
163    "петсто",
164    "шесто",
165    "седамсто",
166    "осамсто",
167    "деветсто",
168];
169
170/// `SCALE[chunk_len]` → `(one, few, many, is_feminine)`.
171///
172/// Python's `SCALE` is a dict with keys 0..=10, i.e. it tops out at 10**30
173/// ("квинтилион"). A chunk index of 11 or more — any value >= 10**33 —
174/// raises `KeyError`, which is the *only* ceiling this language has (there is
175/// no `MAXVAL`, so no `OverflowError`). Serbian uses the long scale, hence
176/// милијарда at 10**9 rather than a short-scale "billion".
177fn scale(chunk_len: usize) -> Result<(&'static str, &'static str, &'static str, bool)> {
178    Ok(match chunk_len {
179        0 => ("", "", "", false),
180        1 => ("хиљада", "хиљаде", "хиљада", true), // 10^3
181        2 => ("милион", "милиона", "милиона", false), // 10^6
182        3 => ("милијарда", "милијарде", "милијарди", true), // 10^9 - long scale
183        4 => ("билион", "билиона", "билиона", false), // 10^12
184        5 => ("билијарда", "билијарде", "билијарди", true), // 10^15
185        6 => ("трилион", "трилиона", "трилиона", false), // 10^18
186        7 => ("трилијарда", "трилијарде", "трилијарди", true), // 10^21
187        8 => ("квадрилион", "квадрилиона", "квадрилиона", false), // 10^24
188        9 => ("квадрилијарда", "квадрилијарде", "квадрилијарди", true), // 10^27
189        10 => ("квинтилион", "квинтилиона", "квинтилиона", false), // 10^30
190        _ => return Err(key_error(&chunk_len.to_string())),
191    })
192}
193
194// --- Python exception encoding -------------------------------------------
195
196/// Python raised `AttributeError`, which `base.rs` cannot express: there is no
197/// `N2WError::Attribute` variant. Following the convention set by
198/// `lang_rm_puter.rs` / `lang_rm_sutsilv.rs`, emit `N2WError::Type` carrying a
199/// message that names the real exception type so the integration layer can
200/// remap it.
201///
202/// **The bridge must map this back to `AttributeError`, not `TypeError`.**
203fn attribute_error(msg: &str) -> N2WError {
204    N2WError::Attribute(msg.to_string())
205}
206
207/// Python raised `KeyError` — a missing `SCALE` entry for values >= 10**33.
208fn key_error(msg: &str) -> N2WError {
209    N2WError::Key(msg.to_string())
210}
211
212// --- utils.py ------------------------------------------------------------
213
214/// `utils.splitbyx(n, 3)` over a decimal string, with `format_int=True`.
215///
216/// `start = length % x` guarantees `length - start` is a multiple of `x`, so
217/// every subsequent slice is exactly `x` long and `i + x <= length` always
218/// holds; the `min` is belt-and-braces against a panic where Python would
219/// simply yield a short slice.
220///
221/// The input is `str(number)` for a **non-negative** number here (`int2word`
222/// strips the sign before calling), so it is pure ASCII digits and byte
223/// slicing is safe.
224fn splitbyx(n: &str, x: usize) -> Vec<u32> {
225    let length = n.len();
226    let mut out = Vec::new();
227    if length > x {
228        let start = length % x;
229        if start > 0 {
230            out.push(n[..start].parse::<u32>().unwrap_or(0));
231        }
232        let mut i = start;
233        while i < length {
234            out.push(n[i..(i + x).min(length)].parse::<u32>().unwrap_or(0));
235            i += x;
236        }
237    } else {
238        out.push(n.parse::<u32>().unwrap_or(0));
239    }
240    out
241}
242
243/// `utils.get_digits(n)` → `(digit_right, digit_mid, digit_left)`.
244///
245/// Python: `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` — zero-pad to
246/// three, keep the last three, reverse. Every chunk from `splitbyx(_, 3)` is
247/// in 0..=999, so the `[-3:]` truncation never actually bites.
248fn get_digits(n: u32) -> (usize, usize, usize) {
249    let s = format!("{:03}", n);
250    let chars: Vec<char> = s.chars().collect();
251    let last3 = &chars[chars.len().saturating_sub(3)..];
252    let d: Vec<usize> = last3
253        .iter()
254        .rev()
255        .map(|c| c.to_digit(10).unwrap_or(0) as usize)
256        .collect();
257    (d[0], d[1], d[2])
258}
259
260/// Parse an all-ASCII-digit slice of a reconstructed numeric string (a
261/// `left`/`right` fragment from the float path) into a `BigInt`.
262///
263/// The inputs are internally generated by `format!`/`BigDecimal::with_scale`,
264/// so they are always non-empty ASCII digits and the parse cannot fail; the
265/// error arm mirrors the `ValueError` Python's `int()` would raise on a bad
266/// token rather than panicking. Leading zeros are accepted, matching `int`.
267fn parse_bigint(s: &str) -> Result<BigInt> {
268    s.parse::<BigInt>()
269        .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
270}
271
272/// Python's `str(number)` for a float/Decimal input — the string
273/// `Num2Word_SR.to_cardinal` splits (`n = str(number).replace(",", ".")`).
274///
275/// * **float** — `repr(float)`. Fixed notation (`{:.*}` at the shim's
276///   repr-derived precision) whenever the repr shows a decimal point;
277///   otherwise repr picked exponent form (`abs(v) >= 1e16`: "1e+16"),
278///   reconstructed by [`python_float_exp_repr`]. The exponent form has no
279///   ".", so `to_cardinal` falls to `int("1e+16")` and raises ValueError —
280///   corpus-pinned (`cardinal(1e+16)` == ValueError), while
281///   `ordinal(1e+16)` (which `int()`s the *value*) succeeds.
282/// * **Decimal** — [`python_decimal_str`], the spec `__str__`: trailing
283///   zeros kept ("5.00"), scientific form for positive exponents ("1E+2",
284///   "1E+20" — again ValueError from `int()`).
285///
286/// Known gap (unpinned): a float in `(0, 1e-4)` also reprs in exponent form
287/// ("1e-05"), but `FloatValue::has_visible_point` reports `true` for every
288/// finite fractional float, so such values take the fixed-notation arm here
289/// and render instead of raising. No `sr_Latn` corpus row reaches that range.
290fn python_number_str(v: &FloatValue) -> String {
291    match v {
292        FloatValue::Float { value, precision } => {
293            if v.has_visible_point() {
294                format!("{:.*}", *precision as usize, value)
295            } else {
296                python_float_exp_repr(*value)
297            }
298        }
299        FloatValue::Decimal { value, .. } => python_decimal_str(value),
300    }
301}
302
303/// Python's `repr(float)` for the exponent-form cases: shortest mantissa
304/// (Rust's `{:e}` is shortest-round-trip, the same contract), "e", an
305/// explicit sign and a >= 2-digit exponent — `1e16` -> "1e+16", `1.5e20` ->
306/// "1.5e+20". Non-finite values print as Python does ("inf"/"-inf"/"nan");
307/// `int()` on those strings raises the same ValueError as on "1e+16".
308fn python_float_exp_repr(v: f64) -> String {
309    if v.is_nan() {
310        return "nan".to_string();
311    }
312    if v.is_infinite() {
313        return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
314    }
315    let s = format!("{:e}", v);
316    match s.split_once('e') {
317        Some((mant, exp)) => {
318            let e: i64 = exp.parse().unwrap_or(0);
319            let sign = if e < 0 { "-" } else { "+" };
320            format!("{}e{}{:02}", mant, sign, e.abs())
321        }
322        None => s,
323    }
324}
325
326/// Extract SR's `feminine=` kwarg (`to_cardinal(self, number,
327/// feminine=False)`, which SR_LATN forwards via `*args, **kwargs`). The flag
328/// only ever feeds `feminine or SCALE[chunk_len][-1]` then
329/// `int(is_feminine)`, so absent / `False` / explicit `None` are all the
330/// masculine column and `True` is the feminine one. Any other key, or a
331/// non-bool value (`feminine=2` would `int()` to a tuple index Python
332/// crashes on), is `NotImplemented` so the dispatcher falls back to the
333/// original Python and its genuine semantics.
334fn feminine_kwarg(kw: &Kwargs) -> Result<bool> {
335    if !kw.only(&["feminine"]) {
336        return Err(N2WError::Fallback("kwargs".into()));
337    }
338    match kw.get("feminine") {
339        Option::None | Some(KwVal::None) => Ok(false),
340        Some(KwVal::Bool(b)) => Ok(*b),
341        Some(_) => Err(N2WError::Fallback("kwargs".into())),
342    }
343}
344
345// --- lang_SR.py ----------------------------------------------------------
346
347/// `Num2Word_SR.pluralize(number, forms)` — the Slavic 1 / 2-4 / other rule.
348fn pluralize(number: u32, forms: (&'static str, &'static str, &'static str, bool)) -> &'static str {
349    let form = if number % 100 < 10 || number % 100 > 20 {
350        if number % 10 == 1 {
351            0
352        } else if number % 10 > 1 && number % 10 < 5 {
353            1
354        } else {
355            2
356        }
357    } else {
358        2
359    };
360    match form {
361        0 => forms.0,
362        1 => forms.1,
363        _ => forms.2,
364    }
365}
366
367/// `Num2Word_SR._int2word`. Returns **Cyrillic**; the caller transliterates.
368fn int2word(number: &BigInt, feminine: bool) -> Result<String> {
369    if number.is_negative() {
370        // Python: " ".join([self.negword, self._int2word(abs(number))]).
371        // Note the dropped `feminine` argument — reproduced verbatim (bug 4).
372        return Ok(format!("{} {}", NEGWORD, int2word(&number.abs(), false)?));
373    }
374    if number.is_zero() {
375        return Ok(ZERO.to_string());
376    }
377
378    let mut words: Vec<&'static str> = Vec::new();
379    let digits = number.to_string();
380    let chunks = splitbyx(&digits, 3);
381    let mut chunk_len = chunks.len();
382
383    for chunk in &chunks {
384        chunk_len -= 1;
385        let (digit_right, digit_mid, digit_left) = get_digits(*chunk);
386
387        if digit_left > 0 {
388            words.push(HUNDREDS[digit_left]);
389        }
390
391        if digit_mid > 1 {
392            words.push(TWENTIES[digit_mid]);
393        }
394
395        if digit_mid == 1 {
396            words.push(TENS[digit_right]);
397        } else if digit_right > 0 {
398            // Skip 'један' for thousands (1000, 1001, etc.)
399            if !(chunk_len > 0 && *chunk == 1) {
400                // SCALE is indexed here *before* the `chunk != 0` guard below,
401                // so a >= 10**33 chunk whose skip-condition misses raises
402                // KeyError at this line in Python. Order preserved.
403                let is_feminine = feminine || scale(chunk_len)?.3;
404                words.push(if is_feminine {
405                    ONES[digit_right].1
406                } else {
407                    ONES[digit_right].0
408                });
409            }
410        }
411
412        if chunk_len > 0 && *chunk != 0 {
413            words.push(pluralize(*chunk, scale(chunk_len)?));
414        }
415    }
416
417    Ok(words.join(" "))
418}
419
420/// `Num2Word_SR.to_cardinal(number, feminine)` for a float/Decimal `number`
421/// — the whole method, both branches, returning **Cyrillic** (the caller
422/// transliterates, exactly like the `cyrl_to_latn(super().to_cardinal(...))`
423/// wrapper):
424///
425/// ```python
426/// n = str(number).replace(",", ".")
427/// if "." in n:
428///     is_negative = n.startswith("-")
429///     left, right = (n[1:] if is_negative else n).split(".")
430///     leading = len(right) - len(right.lstrip("0"))
431///     decimal_part = ("нула " * leading) + self._int2word(int(right), feminine)
432///     result = "%s %s %s" % (self._int2word(int(left), feminine),
433///                            self.pointword, decimal_part)
434///     if is_negative: result = self.negword + " " + result
435/// else:
436///     result = self._int2word(int(n), feminine)
437/// return result
438/// ```
439///
440/// The fraction is one whole number, not a digit sequence (`2.675` reads
441/// "675"), and every leading fraction zero becomes a "нула" word — `right ==
442/// "0"` (from `1.0`) counts as one leading zero *and* `int("0") == 0`, hence
443/// the doubled "нула нула"; `Decimal("5.00")`'s `right == "00"` triples it.
444/// The `else` arm is where exponent-form strings die: `int("1e+16")` /
445/// `int("1E+2")` raise ValueError with Python's exact message (see
446/// [`parse_bigint`]).
447fn cardinal_float_str(value: &FloatValue, feminine: bool) -> Result<String> {
448    // n = str(number).replace(",", ".") — the reconstruction never emits a
449    // comma; the sign stays on the string so `startswith("-")` matches.
450    let n = python_number_str(value);
451
452    if let Some((left, right)) = {
453        let is_negative = n.starts_with('-');
454        let abs_n = if is_negative { &n[1..] } else { &n[..] };
455        abs_n.split_once('.').map(|(l, r)| (l.to_string(), r.to_string()))
456    } {
457        let is_negative = n.starts_with('-');
458        // leading_zero_count = len(right) - len(right.lstrip("0"))
459        let leading = right.chars().take_while(|&c| c == '0').count();
460        let left_int = parse_bigint(&left)?;
461        let right_int = parse_bigint(&right)?;
462
463        // decimal_part = ("нула " * leading) + _int2word(int(right), feminine)
464        let mut decimal_part = String::new();
465        for _ in 0..leading {
466            decimal_part.push_str(ZERO);
467            decimal_part.push(' ');
468        }
469        decimal_part.push_str(&int2word(&right_int, feminine)?);
470
471        // result = "<int(left)> запета <decimal_part>"
472        let body = format!(
473            "{} {} {}",
474            int2word(&left_int, feminine)?,
475            "запета",
476            decimal_part
477        );
478        Ok(if is_negative {
479            format!("{} {}", NEGWORD, body)
480        } else {
481            body
482        })
483    } else {
484        // else: _int2word(int(n), feminine). int(n) keeps the sign, and
485        // int2word renders "минус ..." for a negative; exponent-form and
486        // non-finite strings ("1e+16", "1E+2", "inf") raise ValueError here.
487        int2word(&parse_bigint(&n)?, feminine)
488    }
489}
490
491/// The ordinal lookup table from `Num2Word_SR.to_ordinal` (Cyrillic).
492///
493/// Sparse on purpose: 1..=20, the round tens, 100 and 1000. Everything else
494/// falls through to the cardinal + "и" path (bug 2).
495fn ordinal_word(value: &BigInt) -> Option<&'static str> {
496    let n = value.to_u32()?;
497    Some(match n {
498        1 => "први",
499        2 => "други",
500        3 => "трећи",
501        4 => "четврти",
502        5 => "пети",
503        6 => "шести",
504        7 => "седми",
505        8 => "осми",
506        9 => "девети",
507        10 => "десети",
508        11 => "једанаести",
509        12 => "дванаести",
510        13 => "тринаести",
511        14 => "четрнаести",
512        15 => "петнаести",
513        16 => "шеснаести",
514        17 => "седамнаести",
515        18 => "осамнаести",
516        19 => "деветнаести",
517        20 => "двадесети",
518        30 => "тридесети",
519        40 => "четрдесети",
520        50 => "педесети",
521        60 => "шездесети",
522        70 => "седамдесети",
523        80 => "осамдесети",
524        90 => "деведесети",
525        100 => "стоти",
526        1000 => "хиљадити",
527        _ => return None,
528    })
529}
530
531// --- lang_SR_LATN.py -----------------------------------------------------
532
533/// `_CYRL_TO_LATN` / `cyrl_to_latn` — Serbian Cyrillic → Gaj's Latin.
534///
535/// Python chains 36 `str.replace` calls. Each key is a single codepoint and no
536/// replacement *value* contains a Cyrillic codepoint, so no replacement can be
537/// re-matched by a later pass — the chain is exactly a per-character map, and
538/// running it twice is idempotent (which `to_ordinal` and `to_year` both rely
539/// on; see bug 5).
540///
541/// All 30 letters of the Serbian Cyrillic alphabet are covered. Anything else
542/// (spaces, Latin text, digits) passes through untouched, matching `replace`.
543fn cyrl_to_latn(s: &str) -> String {
544    let mut out = String::with_capacity(s.len());
545    for c in s.chars() {
546        match c {
547            // Digraphs are case-sensitive: one Cyrillic char → two Latin.
548            'Љ' => out.push_str("Lj"),
549            'Њ' => out.push_str("Nj"),
550            'Џ' => out.push_str("Dž"),
551            'љ' => out.push_str("lj"),
552            'њ' => out.push_str("nj"),
553            'џ' => out.push_str("dž"),
554            // Single characters.
555            'А' => out.push('A'),
556            'а' => out.push('a'),
557            'Б' => out.push('B'),
558            'б' => out.push('b'),
559            'В' => out.push('V'),
560            'в' => out.push('v'),
561            'Г' => out.push('G'),
562            'г' => out.push('g'),
563            'Д' => out.push('D'),
564            'д' => out.push('d'),
565            'Ђ' => out.push('Đ'),
566            'ђ' => out.push('đ'),
567            'Е' => out.push('E'),
568            'е' => out.push('e'),
569            'Ж' => out.push('Ž'),
570            'ж' => out.push('ž'),
571            'З' => out.push('Z'),
572            'з' => out.push('z'),
573            'И' => out.push('I'),
574            'и' => out.push('i'),
575            'Ј' => out.push('J'),
576            'ј' => out.push('j'),
577            'К' => out.push('K'),
578            'к' => out.push('k'),
579            'Л' => out.push('L'),
580            'л' => out.push('l'),
581            'М' => out.push('M'),
582            'м' => out.push('m'),
583            'Н' => out.push('N'),
584            'н' => out.push('n'),
585            'О' => out.push('O'),
586            'о' => out.push('o'),
587            'П' => out.push('P'),
588            'п' => out.push('p'),
589            'Р' => out.push('R'),
590            'р' => out.push('r'),
591            'С' => out.push('S'),
592            'с' => out.push('s'),
593            'Т' => out.push('T'),
594            'т' => out.push('t'),
595            'Ћ' => out.push('Ć'),
596            'ћ' => out.push('ć'),
597            'У' => out.push('U'),
598            'у' => out.push('u'),
599            'Ф' => out.push('F'),
600            'ф' => out.push('f'),
601            'Х' => out.push('H'),
602            'х' => out.push('h'),
603            'Ц' => out.push('C'),
604            'ц' => out.push('c'),
605            'Ч' => out.push('Č'),
606            'ч' => out.push('č'),
607            'Ш' => out.push('Š'),
608            'ш' => out.push('š'),
609            other => out.push(other),
610        }
611    }
612    out
613}
614
615// --- currency ------------------------------------------------------------
616
617/// `Num2Word_SR.CURRENCY_FORMS`, transcribed with its **four**-element tuples
618/// intact.
619///
620/// ```python
621/// CURRENCY_FORMS = {
622///     "RUB": (("rublja", "rublje", "rublji", True),
623///             ("kopejka", "kopejke", "kopejki", True)),
624///     "EUR": (("evro", "evra", "evra", False),
625///             ("cent", "centa", "centi", False)),
626///     "RSD": (("dinar", "dinara", "dinara", False),
627///             ("para", "pare", "para", True)),
628/// }
629/// ```
630///
631/// Two things about this table are unusual and both are load-bearing.
632///
633/// **The values are already Latin.** `lang_SR.py` keeps its *number* words in
634/// Cyrillic but wrote the currency nouns in Latin, so `cyrl_to_latn` passes
635/// over them untouched and Cyrillic `sr` inherits Latin currency names. Not our
636/// bug to fix; do not "restore" them to Cyrillic.
637///
638/// **The fourth element is a `bool`, not a string.** It is the grammatical
639/// gender flag `_cents_verbose` hands to `_int2word` as its `feminine`
640/// argument, via `CURRENCY_FORMS[currency][1][-1]` — para and kopejka are
641/// feminine, cent is not. [`CurrencyForms`] can only hold strings, so the flag
642/// is carried as the literal `"True"` / `"False"`. That is not a workaround: it
643/// is *exactly* the text Python produces wherever that slot reaches a format
644/// string. Two consumers depend on the fourth element, and each is satisfied by
645/// this encoding:
646///
647/// 1. `cents_verbose` reads the **subunit's** last element back as a bool
648///    (`== "True"`), reproducing `[1][-1]`. Hence
649///    `to_currency(1.21, "RSD")` → "jedan dinar, dvadeset **jedna** para"
650///    (feminine), while `to_currency(1.21, "EUR")` → "...dvadeset **jedan**
651///    cent" (masculine).
652/// 2. `Num2Word_Base.to_cheque` does
653///    `unit = cr1[-1] if isinstance(cr1, tuple) else cr1`, meaning that for a
654///    4-tuple it picks the **bool** instead of the plural noun and interpolates
655///    it: `"%s AND %s %s" % (words, "56/100", False)`, then `.upper()`. The
656///    corpus pins the result verbatim:
657///
658///    ```text
659///    cheque:EUR 1234.56 -> "HILJADA DVESTA TRIDESET ČETIRI AND 56/100 FALSE"
660///    cheque:RUB -2.00   -> "MINUS DVA AND 00/100 TRUE"
661///    ```
662///
663///    RUB yielding "TRUE" where EUR yields "FALSE" is what proves the slot is
664///    read positionally, not by type. Storing `"False"`/`"True"` puts the right
665///    string where `currency::default_to_cheque`'s `forms.unit.last()` looks,
666///    so `to_cheque` needs no override at all.
667///
668/// **Truncating these to three forms would compile, pass a reading, and
669/// silently emit "... AND 56/100 EVRA" — a corpus failure.** `pluralize` only
670/// ever indexes 0..=2, so the fourth element never leaks into the plural path.
671fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
672    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
673    m.insert(
674        "RUB",
675        CurrencyForms::new(
676            &["rublja", "rublje", "rublji", "True"],
677            &["kopejka", "kopejke", "kopejki", "True"],
678        ),
679    );
680    m.insert(
681        "EUR",
682        CurrencyForms::new(
683            &["evro", "evra", "evra", "False"],
684            &["cent", "centa", "centi", "False"],
685        ),
686    );
687    m.insert(
688        "RSD",
689        CurrencyForms::new(
690            &["dinar", "dinara", "dinara", "False"],
691            &["para", "pare", "para", "True"],
692        ),
693    );
694    m
695}
696
697pub struct LangSrLatn {
698    /// Built once in [`LangSrLatn::new`]. `lib.rs` holds the language in a
699    /// `OnceLock`, so this table is constructed a single time per process and
700    /// `to_currency` only ever reads it.
701    currency_forms: HashMap<&'static str, CurrencyForms>,
702}
703
704impl LangSrLatn {
705    pub fn new() -> Self {
706        LangSrLatn {
707            currency_forms: build_currency_forms(),
708        }
709    }
710}
711
712impl Default for LangSrLatn {
713    fn default() -> Self {
714        LangSrLatn::new()
715    }
716}
717
718impl Lang for LangSrLatn {
719    fn negword(&self) -> &str {
720        NEGWORD
721    }
722
723    fn pointword(&self) -> &str {
724        "запета"
725    }
726
727    /// `SR_LATN.to_cardinal` → `cyrl_to_latn(SR.to_cardinal(...))`.
728    ///
729    /// `SR.to_cardinal` does `str(number).replace(",", ".")` and branches on a
730    /// "." — integer input never contains one, so this reduces to
731    /// `_int2word(int(n), feminine=False)`.
732    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
733        Ok(cyrl_to_latn(&int2word(value, false)?))
734    }
735
736    /// `SR_LATN.to_ordinal` → `cyrl_to_latn(SR.to_ordinal(...))`.
737    ///
738    /// The `try: int(number) except (ValueError, TypeError): return
739    /// str(number)` guard in Python can never fire for the BigInt we are
740    /// handed, so it is elided.
741    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
742        if let Some(word) = ordinal_word(value) {
743            return Ok(cyrl_to_latn(word));
744        }
745        // Python: `cardinal = self.to_cardinal(num)` dispatches to the SR_LATN
746        // override, so this is already Latin; "и" is Cyrillic; the outer
747        // wrapper transliterates the concatenation. Bug 5 — kept verbatim.
748        let cardinal = self.to_cardinal(value)?;
749        Ok(cyrl_to_latn(&format!("{}{}", cardinal, "и")))
750    }
751
752    /// Neither `SR_LATN` nor `SR` overrides `to_ordinal_num`, so it resolves to
753    /// `Num2Word_Base.to_ordinal_num`, which returns `value` **unchanged** — an
754    /// `int`. `SR_LATN.to_ordinal_num` then feeds that int to `cyrl_to_latn`,
755    /// whose first statement is `s.replace("Љ", "Lj")`:
756    ///
757    /// ```text
758    /// AttributeError: 'int' object has no attribute 'replace'
759    /// ```
760    ///
761    /// Unconditional — the corpus has zero successful `ordinal_num` rows for
762    /// this language. Bug 1.
763    fn to_ordinal_num(&self, _value: &BigInt) -> Result<String> {
764        Err(attribute_error(
765            "'int' object has no attribute 'replace'",
766        ))
767    }
768
769    /// `SR_LATN.to_year` → `cyrl_to_latn(Num2Word_Base.to_year(...))`, and
770    /// `Base.to_year` is just `self.to_cardinal(value)` — which dispatches back
771    /// to the SR_LATN override, so the string is already Latin and the outer
772    /// `cyrl_to_latn` is a no-op. No era handling, no two-digit splitting.
773    fn to_year(&self, value: &BigInt) -> Result<String> {
774        Ok(cyrl_to_latn(&self.to_cardinal(value)?))
775    }
776
777    /// `SR_LATN.to_cardinal(number)` for **non-integer** input — the float and
778    /// Decimal cardinal path.
779    ///
780    /// `Num2Word_SR` does **not** override `to_cardinal_float`. Instead its
781    /// `to_cardinal` services floats/Decimals inline by string-splitting
782    /// `str(number)` (`lang_SR.py`), and the `num2words` dispatcher routes float
783    /// input through `converter.to_cardinal(number)` — never through
784    /// `to_cardinal_float`. So the inherited `default_to_cardinal_float` is the
785    /// *wrong* behaviour for this language (it renders the fractional part
786    /// digit-by-digit and leaves the Cyrillic pointword untransliterated, e.g.
787    /// `2.675` → "dva запета šest sedam pet"); [`cardinal_float_str`]
788    /// reproduces the SR string algorithm instead, and this wrapper is
789    /// `cyrl_to_latn(Num2Word_SR.to_cardinal(number))`.
790    ///
791    /// `precision_override` (issue #580) is a **documented no-op**:
792    /// `SR.to_cardinal` never consults `self.precision`, so
793    /// `num2words(2.675, lang="sr_Latn", precision=1)` is unchanged — verified
794    /// against the live interpreter. This hook also serves the
795    /// fractional-cents currency path (`cardinal_from_decimal` ->
796    /// `cardinal_from_bigdecimal` dispatches through the trait), mirroring
797    /// Python's virtual `self.to_cardinal(float(right))`.
798    fn to_cardinal_float(
799        &self,
800        value: &FloatValue,
801        _precision_override: Option<u32>,
802    ) -> Result<String> {
803        Ok(cyrl_to_latn(&cardinal_float_str(value, false)?))
804    }
805
806    /// `to_cardinal(float/Decimal)` — the FULL entry, whole values included.
807    ///
808    /// SR branches on `"." in str(number)`, so a whole value with a visible
809    /// point still takes the float grammar: `5.0` -> "pet zapeta nula nula",
810    /// `Decimal("5.00")` -> "pet zapeta nula nula nula", `-0.0` -> "minus
811    /// nula zapeta nula nula". A pointless string falls to `int(n)`:
812    /// `Decimal("5")` -> "pet", but `1e+16` / `Decimal("1E+2")` raise
813    /// ValueError. The base default (whole -> int path) would get every one
814    /// of those wrong, hence this override.
815    fn cardinal_float_entry(
816        &self,
817        value: &FloatValue,
818        _precision_override: Option<u32>,
819    ) -> Result<String> {
820        Ok(cyrl_to_latn(&cardinal_float_str(value, false)?))
821    }
822
823    /// `to_ordinal(float/Decimal)`. `SR.to_ordinal` opens with
824    /// `num = int(number)` — `int()` of the *value*, truncation toward zero —
825    /// so `2.5` -> 2 -> "drugi", `-1.5` -> -1 -> "minus jedani", `0.5` ->
826    /// 0 -> "nulai", and `1e+16` *succeeds* ("deset bilijardii") where the
827    /// cardinal raises ValueError.
828    ///
829    /// The `except (ValueError, TypeError): return str(number)` guard can
830    /// only fire here for `nan` (`int(nan)` is ValueError, so Python returns
831    /// `str(nan)` == "nan", which `cyrl_to_latn` passes through); `int(inf)`
832    /// raises OverflowError, which the guard does **not** catch. Neither has
833    /// a corpus row; both are mirrored for completeness.
834    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
835        let num = match value {
836            FloatValue::Float { value: f, .. } => {
837                if f.is_nan() {
838                    return Ok("nan".to_string());
839                }
840                BigInt::from_f64(f.trunc()).ok_or_else(|| {
841                    N2WError::Overflow("cannot convert float infinity to integer".into())
842                })?
843            }
844            // int(Decimal) truncates toward zero; with_scale(0) drops the
845            // fractional digits the same way (see floatpath::float2tuple).
846            FloatValue::Decimal { value: d, .. } => d.with_scale(0).as_bigint_and_exponent().0,
847        };
848        // Dispatches to the SR_LATN to_ordinal override above — table word or
849        // Latin cardinal + "и", double-transliterated exactly as Python.
850        self.to_ordinal(&num)
851    }
852
853    /// `to_ordinal_num(float/Decimal)` — bug 1, float edition. Base's
854    /// `to_ordinal_num` returns the value **unchanged** (a `float` or a
855    /// `Decimal`), and `SR_LATN.to_ordinal_num` hands it to `cyrl_to_latn`,
856    /// whose first statement is `s.replace("Љ", "Lj")`:
857    ///
858    /// ```text
859    /// AttributeError: 'float' object has no attribute 'replace'
860    /// AttributeError: 'decimal.Decimal' object has no attribute 'replace'
861    /// ```
862    ///
863    /// Unconditional — every `ordinal_num` row in the corpus (int, float and
864    /// Decimal alike) is an AttributeError. Only the message differs by type.
865    fn ordinal_num_float_entry(&self, value: &FloatValue, _repr_str: &str) -> Result<String> {
866        let type_name = match value {
867            FloatValue::Float { .. } => "float",
868            FloatValue::Decimal { .. } => "decimal.Decimal",
869        };
870        Err(attribute_error(&format!(
871            "'{}' object has no attribute 'replace'",
872            type_name
873        )))
874    }
875
876    // `year_float_entry` is deliberately NOT overridden: `SR_LATN.to_year` is
877    // `cyrl_to_latn(Base.to_year(...))` == `cyrl_to_latn(self.to_cardinal(...))`,
878    // and the trait default routes through the overridden
879    // `cardinal_float_entry` above — so `to_year(5.0)` == "pet zapeta nula
880    // nula" and `to_year(1e+16)` raises ValueError, as pinned.
881
882    /// `converter.str_to_number` — Base's `Decimal(value)`, which neither SR
883    /// nor SR_LATN overrides. The `Inf` interception reproduces what happens
884    /// *next* on the pinned path: `to_cardinal(Decimal("Infinity"))` reads
885    /// `n = str(number)` == "Infinity", finds no ".", and dies in
886    /// `int("Infinity")` with ValueError. The binding otherwise maps
887    /// `ParsedNumber::Inf` to the base integer path's OverflowError before
888    /// any SR code runs, so the ValueError must be raised here. (NaN needs no
889    /// interception: the binding's ValueError already matches `int("NaN")`'s
890    /// type.)
891    ///
892    /// Known gap (unpinned): Python's `to_ordinal(Decimal("Infinity"))`
893    /// raises OverflowError (`int()` of the Decimal *object*), which this
894    /// entry-level interception turns into the cardinal path's ValueError.
895    /// The strings corpus pins Infinity under `to=cardinal` only.
896    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
897        match python_decimal_parse(s)? {
898            ParsedNumber::Inf { negative } => Err(N2WError::Value(format!(
899                "invalid literal for int() with base 10: '{}Infinity'",
900                if negative { "-" } else { "" }
901            ))),
902            other => Ok(other),
903        }
904    }
905
906    // ---- grammatical kwargs ----------------------------------------------
907
908    /// `SR_LATN.to_cardinal(*args, **kwargs)` forwards SR's `feminine=`
909    /// kwarg: `to_cardinal(2, feminine=True)` == "dve". The negative
910    /// recursion inside `_int2word` still drops the flag (bug 4):
911    /// `to_cardinal(-5, feminine=True)` == "minus pet".
912    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
913        let feminine = feminine_kwarg(kw)?;
914        Ok(cyrl_to_latn(&int2word(value, feminine)?))
915    }
916
917    /// The float/Decimal side of the same kwarg: Python's `to_cardinal`
918    /// threads `feminine` into *both* `_int2word` calls of the "." branch.
919    fn to_cardinal_float_kw(
920        &self,
921        value: &FloatValue,
922        _precision_override: Option<u32>,
923        kw: &Kwargs,
924    ) -> Result<String> {
925        let feminine = feminine_kwarg(kw)?;
926        Ok(cyrl_to_latn(&cardinal_float_str(value, feminine)?))
927    }
928
929    // ---- currency -------------------------------------------------------
930    //
931    // `Num2Word_SR` overrides `to_currency`, `pluralize` and `_cents_verbose`;
932    // `Num2Word_SR_LATN` wraps `to_currency` in `cyrl_to_latn`. Everything else
933    // on the currency surface is inherited from `Num2Word_Base` unchanged and
934    // is already mirrored by the trait defaults, so it is deliberately absent
935    // here:
936    //
937    // * `_money_verbose` → `self.to_cardinal(number)`, which dispatches to the
938    //   SR_LATN override and so already returns Latin.
939    // * `_cents_terse`   → `CURRENCY_PRECISION.get(currency, 100)` is 100 for
940    //   every code, so the default's `"%02d"` width is right.
941    // * `CURRENCY_PRECISION` is `{}` (never rebound), so `currency_precision`
942    //   stays at the trait's flat 100 — SR has no 3-decimal and no 0-decimal
943    //   currency, and `default_to_currency`'s `divisor == 1` branch is
944    //   unreachable. This is *why* `currency:JPY 12.34` raises
945    //   NotImplementedError instead of rounding to a whole yen.
946    // * `CURRENCY_ADJECTIVES` is `{}`, so `currency_adjective` stays `None` and
947    //   `adjective=True` is a no-op — matching `if adjective and currency in
948    //   self.CURRENCY_ADJECTIVES`.
949    // * `to_cheque` is Base's; see `build_currency_forms` for why the default
950    //   reproduces its bool-instead-of-noun bug without an override.
951
952    /// `self.__class__.__name__` for the NotImplementedError message. The
953    /// raise sites live in `base.py` and `lang_SR.py`, but the *instance* is a
954    /// `Num2Word_SR_LATN`, so that is the name Python interpolates.
955    fn lang_name(&self) -> &str {
956        "Num2Word_SR_LATN"
957    }
958
959    /// `Num2Word_SR.to_currency(val, currency="RSD", ...)`.
960    ///
961    /// This override is hand-added rather than generated. The generator reads
962    /// `inspect.signature(converter.to_currency)`, and `Num2Word_SR_LATN`'s is
963    /// `(*args, **kwargs)` — the real defaults sit one level up on
964    /// `Num2Word_SR.to_currency`, invisible to `signature()`. sr_Latn is the
965    /// only affected language whose default is not Base's "EUR", so it is the
966    /// only one where the omission is observable:
967    ///
968    /// ```text
969    /// num2words(1.5, lang="sr_Latn", to="currency")
970    ///   == "jedan dinar, pedeset para"      # RSD, verified against Python
971    ///   != "jedan evro, pedeset centi"      # what Base's "EUR" default gives
972    /// ```
973    ///
974    /// (`separator` needs no such override: SR's default is `","`, identical to
975    /// Base's, so the trait default is already correct.)
976    fn default_currency(&self) -> &str {
977        "RSD"
978    }
979
980    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
981        self.currency_forms.get(code)
982    }
983
984    /// `Num2Word_SR.pluralize(number, forms)` — the Slavic one / few / many
985    /// rule:
986    ///
987    /// ```python
988    /// if number % 100 < 10 or number % 100 > 20:
989    ///     if number % 10 == 1:      form = 0
990    ///     elif 1 < number % 10 < 5: form = 1
991    ///     else:                     form = 2
992    /// else:
993    ///     form = 2
994    /// return forms[form]
995    /// ```
996    ///
997    /// Distinct from the module-level `pluralize` helper, which serves
998    /// `SCALE`'s 4-tuples from inside [`int2word`]. This one is the trait hook
999    /// `currency::default_to_currency` calls with `CURRENCY_FORMS` entries.
1000    ///
1001    /// `mod_floor` rather than `%` because Python's `%` floors on negatives.
1002    /// Every caller hands us a non-negative `left`/`right` straight out of
1003    /// `parse_currency_parts`, so the two agree today — the port simply should
1004    /// not depend on that.
1005    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1006        let ten = BigInt::from(10);
1007        let m100 = n.mod_floor(&BigInt::from(100));
1008        let m10 = n.mod_floor(&ten);
1009
1010        let form = if m100 < ten || m100 > BigInt::from(20) {
1011            if m10.is_one() {
1012                0
1013            } else if m10 > BigInt::one() && m10 < BigInt::from(5) {
1014                1
1015            } else {
1016                2
1017            }
1018        } else {
1019            2
1020        };
1021
1022        // Python indexes the tuple directly. Every SR entry carries four forms
1023        // so this cannot miss, but a short tuple would raise IndexError rather
1024        // than panic, and the exception type is what a caller can observe.
1025        forms
1026            .get(form)
1027            .cloned()
1028            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1029    }
1030
1031    /// `Num2Word_SR._cents_verbose`:
1032    /// `self._int2word(number, self.CURRENCY_FORMS[currency][1][-1])`.
1033    ///
1034    /// Returns **Cyrillic**, exactly as Python does: `_cents_verbose` is only
1035    /// ever reached from inside `to_currency`, whose SR_LATN wrapper
1036    /// transliterates the finished string. Nothing else calls this hook, and
1037    /// the bindings do not expose it, so the Cyrillic cannot escape.
1038    ///
1039    /// `[1][-1]` is the *subunit* tuple's gender flag — see
1040    /// [`build_currency_forms`] for why it round-trips through `"True"`.
1041    fn cents_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
1042        // Python would raise KeyError here for an unknown code, but the lookup
1043        // is unreachable: `default_to_currency` resolves the same table first
1044        // and has already raised NotImplementedError by this point.
1045        let forms = self
1046            .currency_forms
1047            .get(currency)
1048            .ok_or_else(|| key_error(currency))?;
1049        let feminine = forms.subunit.last().map(|s| s == "True").unwrap_or(false);
1050        int2word(number, feminine)
1051    }
1052
1053    /// `Num2Word_SR_LATN.to_currency` →
1054    /// `cyrl_to_latn(Num2Word_SR.to_currency(*args, **kwargs))`.
1055    ///
1056    /// `Num2Word_SR.to_currency` splits in two: it services `int` itself and
1057    /// hands everything else to `Num2Word_Base.to_currency` via `super()`. The
1058    /// int arm is bug 6 — it never looks at `currency`.
1059    fn to_currency(
1060        &self,
1061        val: &CurrencyValue,
1062        currency: &str,
1063        cents: bool,
1064        separator: Option<&str>,
1065        adjective: bool,
1066    ) -> Result<String> {
1067        // The trait hands us None when the caller omitted `separator=`;
1068        // resolve it before the ported body. SR's default is Base's ",".
1069        let separator = separator.unwrap_or(self.default_separator());
1070
1071        if let CurrencyValue::Int(v) = val {
1072            // parse_currency_parts(val, is_int_with_cents=False) reduces to
1073            // `(abs(val), 0, val < 0)` — the divisor is never consulted.
1074            let is_negative = v.is_negative();
1075            let left = v.abs();
1076
1077            let mut words: Vec<String> = Vec::new();
1078            if is_negative {
1079                // Python hardcodes this literal rather than using self.negword.
1080                // They happen to be the same string; kept as the literal so the
1081                // two stay independent if negword ever moves.
1082                words.push("минус".to_string());
1083            }
1084            // `self.to_cardinal(left)` dispatches to the SR_LATN override, so
1085            // this fragment arrives already Latin and the outer cyrl_to_latn
1086            // re-runs over it idempotently — bug 5's shape again. `left` is
1087            // non-negative, so `_int2word` never prepends its own "минус".
1088            // A `left >= 10**33` still raises KeyError from `scale`, exactly as
1089            // Python does: the int arm has no ceiling of its own.
1090            words.push(self.to_cardinal(&left)?);
1091
1092            // Python's `elif 2 <= left % 10 <= 4 and not (12 <= left % 100 <= 14)`
1093            // and its `else` both append "динара", so the elif is dead and the
1094            // whole cascade collapses to this. Note 11 -> "динара" (the
1095            // `% 100 != 11` guard) but 21 -> "динар".
1096            let one_dinar = left.mod_floor(&BigInt::from(10)).is_one()
1097                && left.mod_floor(&BigInt::from(100)) != BigInt::from(11);
1098            let unit = if one_dinar {
1099                "динар"
1100            } else {
1101                "динара"
1102            };
1103            words.push(unit.to_string());
1104
1105            // `currency`, `cents`, `separator` and `adjective` are all dropped
1106            // on this path — an unknown code raises nothing and still says
1107            // dinars. Bug 6.
1108            let _ = (currency, cents, separator, adjective);
1109            return Ok(cyrl_to_latn(&words.join(" ")));
1110        }
1111
1112        // Floats/Decimals: `super().to_currency(...)` — Num2Word_Base's, which
1113        // is what raises NotImplementedError for a code outside the table.
1114        // The `?` propagates that error untransliterated, as Python's raise
1115        // unwinds past `cyrl_to_latn` without calling it.
1116        let out = default_to_currency(self, val, currency, cents, separator, adjective)?;
1117        Ok(cyrl_to_latn(&out))
1118    }
1119}