Skip to main content

num2words2_core/
lang_sr.rs

1//! Port of `lang_SR.py` (Serbian, Cyrillic script).
2//!
3//! Registry: `__init__.py` maps **both** `"sr"` and `"sr_Cyrl"` to
4//! `lang_SR.Num2Word_SR()`. (`"sr_Latn"` is a different class,
5//! `Num2Word_SR_LATN`, and is not this file's concern.)
6//!
7//! Shape: **self-contained**. `Num2Word_SR` subclasses `Num2Word_Base` but
8//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
9//! `hasattr` guard in `Num2Word_Base.__init__` never fires: Python builds
10//! neither `self.cards` nor `self.MAXVAL`. `to_cardinal` is overridden
11//! outright and drives `_int2word` over 3-digit chunks. Consequently
12//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
13//! **no overflow check** — the only ceiling is the `SCALE` table, which
14//! raises `KeyError` rather than `OverflowError` (see below).
15//!
16//! `setup()` sets `negword = "минус"` and `pointword = "запета"`. The
17//! pointword is reached through the float/Decimal branch of `to_cardinal`,
18//! ported below as [`LangSr::cardinal_float_str`].
19//!
20//! Inherited from `Num2Word_Base` (unchanged by SR, so the trait defaults do
21//! the right thing):
22//!   * `to_ordinal_num(value) -> value`  → default `Ok(value.to_string())`
23//!   * `to_year(value)        -> self.to_cardinal(value)` → default delegates
24//!     through `&self`, picking up the `to_cardinal` override below. There is
25//!     no era/BC handling: `to_year(-500)` == `to_cardinal(-500)` ==
26//!     "минус петсто".
27//!
28//! # The float branch of `to_cardinal`
29//!
30//! Python's `to_cardinal` starts with `n = str(number).replace(",", ".")` and
31//! branches on `"." in n`. For integral input (`to_cardinal`, [`LangSr::to_cardinal`])
32//! `str(BigInt)` never contains `.` or `,`, so control reaches the `else` arm:
33//! `self._int2word(int(n), feminine)`. Non-integer input (a Python `float` or
34//! `Decimal`) reaches the `"." in n` arm, ported here as
35//! [`LangSr::to_cardinal_float`] — `pointword`, leading-zero "нула" prefixes and
36//! the whole-number rendering of the fraction included. Because SR overrides
37//! `to_cardinal` rather than `to_cardinal_float`, that override is what the
38//! dispatcher reaches for floats; SR never inherits `Num2Word_Base`'s
39//! digit-by-digit `to_cardinal_float`.
40//!
41//! Because the branch test is on the *string*, the full float entry
42//! ([`Lang::cardinal_float_entry`]) is overridden to send **every**
43//! float/Decimal — whole values included — through the string algorithm:
44//! `to_cardinal(5.0)` reads `str(5.0)` == "5.0", finds the ".", and renders
45//! "пет запета нула нула" where the base default would say "пет". Values
46//! whose Python string form has *no* "." fall to `int(n)`:
47//!
48//! * `Decimal("5")`, `Decimal("500")` — plain digits, integer path.
49//! * `1e+16`, `1e+20` (repr exponent form) and `Decimal("1E+2")` /
50//!   `Decimal("1E+20")` (spec `__str__` exponent form) — `int("1e+16")`
51//!   raises **ValueError** ("invalid literal for int() with base 10: ...").
52//!   That is Python's crash, corpus-pinned, and reproduced by
53//!   [`python_number_str`].
54//! * `Decimal("Infinity")` (string input "Infinity") — `int("Infinity")` is
55//!   the same ValueError; see the `str_to_number` override.
56//!
57//! `to_year` is Base's `self.to_cardinal(value)`, so the default
58//! `year_float_entry` (→ the overridden `cardinal_float_entry`) is already
59//! right. `to_ordinal` `int()`s the **value**, not the string — truncation
60//! toward zero — so `to_ordinal(2.5)` == "други" and `to_ordinal(1e+16)`
61//! *succeeds* ("десет билијардии") where the cardinal raises; see the
62//! `ordinal_float_entry` override below.
63//!
64//! # Grammatical kwargs
65//!
66//! `to_cardinal(self, number, feminine=False)` is the only SR signature with
67//! an extra kwarg. `feminine` flips the `ONES` gender column
68//! (`to_cardinal(2, feminine=True)` == "две") — except on the negative
69//! recursion, which drops it (quirk 5), and in the teens (one form only).
70//! Ported in `to_cardinal_kw`/`to_cardinal_float_kw`; only `bool` and `None`
71//! values are accepted (Python treats `feminine=None` as falsy via
72//! `feminine or SCALE[...]`; a truthy non-bool like `feminine=2` would
73//! `int()` to an out-of-range tuple index — those fall back to Python).
74//!
75//! # Faithfully reproduced Python quirks
76//!
77//! This is a port, not a rewrite. All of the following are exactly what
78//! Python emits; none are "fixed" here:
79//!
80//! 1. **`to_ordinal` is a lookup table plus a naive "и" suffix.** Only the
81//!    keys 1..=20, 30, 40, ..., 90, 100 and 1000 have real ordinal words.
82//!    Every other input falls through to `self.to_cardinal(num) + "и"`,
83//!    which produces non-words. Verified against the corpus:
84//!    `to_ordinal(0)` == "нулаи", `to_ordinal(21)` == "двадесет једани",
85//!    `to_ordinal(1100)` == "хиљада стои", `to_ordinal(2000)` ==
86//!    "две хиљадеи", `to_ordinal(10**9)` == "милијардаи". The Python source
87//!    even flags itself: "This is a simplified implementation".
88//! 2. **Negative ordinals are suffixed, not rejected.** `to_ordinal(-1)` ==
89//!    "минус једани". `Num2Word_Base` has an `errmsg_negord` message, but SR
90//!    never consults it, so no error is raised. (Contrast `lang_PL`, which
91//!    crashes with `ValueError` on every negative ordinal — SR does not,
92//!    because `_int2word` takes `abs()` *before* the string is chunked, so
93//!    the "-" never reaches `splitbyx`/`get_digits`.)
94//! 3. **`pluralize` treats `n % 100 == 10` as a teen.** The guard is
95//!    `if number % 100 < 10 or number % 100 > 20`, so a remainder of exactly
96//!    10 falls to the `else` and takes form 2. Hence `to_cardinal(10**10)`
97//!    == "десет милијарди" (genitive plural), not "десет милијарда".
98//! 4. **The "skip један" test compares the whole chunk, not the digit.**
99//!    Python writes `if not (chunk_len > 0 and chunk == 1)`. So the unit word
100//!    is suppressed only when the scaled chunk is *exactly* 1 — giving
101//!    "хиљада" for 1000 and "милион" for 10**6 — while 1001's low chunk
102//!    (chunk_len == 0) still renders "један": "хиљада један".
103//! 5. **`_int2word` drops `feminine` on the negative recursion.** Python:
104//!    `" ".join([self.negword, self._int2word(abs(number))])` — the
105//!    `feminine` argument is not forwarded, so `_int2word(-2, feminine=True)`
106//!    yields "минус два" rather than "минус две". Unreachable from the four
107//!    ported modes (they always pass `feminine=False`), but reproduced.
108//! 6. **`SCALE` is Serbian's de facto MAXVAL, and it raises `KeyError`.**
109//!    Keys run 0..=10, i.e. up to 10^30 ("квинтилион"), covering values below
110//!    10^33. At or above 10^33 a chunk index of 11+ is reached and Python
111//!    dies with `KeyError: 11`. See [`scale_at`] and the note on access order
112//!    in [`LangSr::int2word`].
113//! 7. **Long scale.** `SCALE[3]` is "милијарда" for 10^9 and `SCALE[4]` is
114//!    "билион" for 10^12, as the Python comments state explicitly.
115//!
116//! # The currency surface
117//!
118//! `Num2Word_SR` defines its **own** `CURRENCY_FORMS` class attribute, so the
119//! `lang_EUR.py`/`Num2Word_EN.__init__` shared-dict mutation documented in
120//! `PORTING_CURRENCY.md` does **not** reach it: SR knows exactly three codes
121//! (RUB, EUR, RSD) and nothing else. It defines neither `CURRENCY_ADJECTIVES`
122//! nor `CURRENCY_PRECISION`, so both stay at `Num2Word_Base`'s empty dicts —
123//! every code has precision `.get(code, 100)` == 100. SR therefore has **no**
124//! 3-decimal (KWD/BHD) and no 0-decimal (JPY) currency, and the `divisor == 1`
125//! branch of `Num2Word_Base.to_currency` is unreachable here. Verified live.
126//!
127//! Each `CURRENCY_FORMS` entry is a pair of **4-tuples**: three plural forms
128//! plus a trailing gender flag, e.g. `("dinar", "dinara", "dinara", False)`.
129//! `pluralize` only ever selects index 0..=2, so the flag is never a plural
130//! form; `_cents_verbose` reads it as `[1][-1]` to pick the cents' gender.
131//! See [`build_currency_forms`] for why the flag is carried as the *string*
132//! `"False"`/`"True"` rather than a `bool`.
133//!
134//! # Faithfully reproduced Python quirks (currency)
135//!
136//! 8. **`to_currency` ignores `currency=` entirely for `int` input, and can
137//!    never raise `NotImplementedError` there.** SR's override intercepts
138//!    `isinstance(val, int)` *before* any `CURRENCY_FORMS` lookup and appends a
139//!    hardcoded "динар"/"динара". So `to_currency(1, currency="JPY")` ==
140//!    "један динар", and even a nonexistent code succeeds:
141//!    `to_currency(1, currency="ZZZ")` == "један динар" (verified live). Only
142//!    the float/Decimal path reaches `Num2Word_Base.to_currency` and its
143//!    `KeyError` -> `NotImplementedError`. The corpus pins both halves: every
144//!    `currency:{USD,GBP,JPY,KWD,BHD,INR,CNY,CHF}` int row returns dinars while
145//!    every float row of the same code raises.
146//! 9. **The `elif` in that dinar rule is dead code.** Python writes
147//!    `if left % 10 == 1 and left % 100 != 11: "динар"` /
148//!    `elif 2 <= left % 10 <= 4 and not (12 <= left % 100 <= 14): "динара"` /
149//!    `else: "динара"` — the last two arms are byte-identical, so the elif can
150//!    never change the output. Collapsed to a single `else` here; see
151//!    [`LangSr::to_currency`].
152//! 10. **`_money_verbose` drops the unit's gender.** It is `Num2Word_Base`'s,
153//!    i.e. `self.to_cardinal(number)`, which passes `feminine=False`. So a
154//!    feminine unit still gets a masculine numeral: `to_currency(1.0, "RUB")`
155//!    == "један rublja, нула kopejki" — "једна rublja" would be the correct
156//!    Serbian. Only `_cents_verbose` consults the flag, so the *cents* half is
157//!    gendered correctly ("нула kopejki", "једна para"). Verified live.
158//! 11. **`to_cheque` prints the gender flag as the currency name.**
159//!    `Num2Word_Base.to_cheque` does `cr1, _cr2 = self.CURRENCY_FORMS[currency]`
160//!    then `unit = cr1[-1] if isinstance(cr1, tuple) else cr1`, intending "the
161//!    plural form". SR's `cr1` is a 4-tuple, so `cr1[-1]` is the trailing
162//!    **bool**, which `"%s"` renders as `False`/`True` and `.upper()` shouts:
163//!    `to_cheque(1234.56, "EUR")` ==
164//!    "ХИЉАДА ДВЕСТА ТРИДЕСЕТ ЧЕТИРИ AND 56/100 FALSE", and RUB (flag `True`)
165//!    ends in "TRUE". Pinned by the corpus; reproduced exactly.
166//!
167//! # Fractional cents (`cardinal_from_decimal`) — closed by dynamic dispatch
168//!
169//! Reached only when `(value * 100) % 1 != 0` on the currency path. Python
170//! evaluates `self.to_cardinal(float(right))`, which lands in
171//! **`Num2Word_SR.to_cardinal`'s float branch**. The trait default
172//! (`floatpath::cardinal_from_bigdecimal`) calls `lang.to_cardinal_float`
173//! *through the trait*, and SR overrides that hook with the very same string
174//! algorithm, so the fractional digits render as a whole number ("петсто
175//! шездесет седам"), not digit by digit — matching Python with no
176//! `cardinal_from_decimal` override. (Unpinned: no `sr` currency corpus row
177//! carries more than 2 decimals.)
178//!
179//! # Error variants
180//!
181//! Serbian's only in-scope failure is the `SCALE` `KeyError` of quirk 6,
182//! mapped to `N2WError::Key`. It is a Python crash rather than a deliberate
183//! raise, but the exception *type* is observable, so parity means
184//! reproducing it rather than tidying it into an `OverflowError`.
185//!
186//! On the currency side the only reachable raise is the deliberate
187//! `NotImplementedError` for an unknown code on the float path, which
188//! `currency::default_to_currency`/`default_to_cheque` already emit with
189//! Python's exact message from [`LangSr::lang_name`].
190
191use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
192use crate::currency::{default_to_currency, parse_currency_parts, CurrencyForms, CurrencyValue};
193use crate::floatpath::FloatValue;
194use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
195use num_bigint::BigInt;
196use num_integer::Integer;
197use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
198use std::collections::HashMap;
199
200/// `ZERO[0]`.
201const ZERO: &str = "нула";
202
203/// `setup()`: `self.negword`.
204const NEGWORD: &str = "минус";
205
206/// `setup()`: `self.pointword`. Exposed via the `pointword()` trait method,
207/// but unreachable from the four ported modes: only the float branch of
208/// `to_cardinal` (out of scope) consumes it.
209const POINTWORD: &str = "запета";
210
211/// `ONES`: digit → (masculine, feminine). Python's dict has keys 1..=9 only;
212/// index 0 is a placeholder, unreachable behind the `digit_right > 0` guard.
213const ONES: [(&str, &str); 10] = [
214    ("", ""), // absent in Python
215    ("један", "једна"),
216    ("два", "две"),
217    ("три", "три"),
218    ("четири", "четири"),
219    ("пет", "пет"),
220    ("шест", "шест"),
221    ("седам", "седам"),
222    ("осам", "осам"),
223    ("девет", "девет"),
224];
225
226/// `TENS`: keys 0..=9, indexed by the *units* digit when the tens digit is 1.
227/// `TENS[0]` == "десет" (10), `TENS[1]` == "једанаест" (11), etc.
228const TENS: [&str; 10] = [
229    "десет",
230    "једанаест",
231    "дванаест",
232    "тринаест",
233    "четрнаест",
234    "петнаест",
235    "шеснаест",
236    "седамнаест",
237    "осамнаест",
238    "деветнаест",
239];
240
241/// `TWENTIES`: Python's dict has keys 2..=9 only; 0 and 1 are placeholders,
242/// unreachable behind the `digit_mid > 1` guard.
243const TWENTIES: [&str; 10] = [
244    "", // absent in Python
245    "", // absent in Python
246    "двадесет",
247    "тридесет",
248    "четрдесет",
249    "педесет",
250    "шездесет",
251    "седамдесет",
252    "осамдесет",
253    "деведесет",
254];
255
256/// `HUNDREDS`: Python's dict has keys 1..=9 only; index 0 is a placeholder,
257/// unreachable behind the `digit_left > 0` guard.
258const HUNDREDS: [&str; 10] = [
259    "", // absent in Python
260    "сто",
261    "двеста",
262    "триста",
263    "четиристо",
264    "петсто",
265    "шесто",
266    "седамсто",
267    "осамсто",
268    "деветсто",
269];
270
271/// `SCALE`: chunk index → (form0, form1, form2, is_feminine).
272///
273/// Keys 0..=10 exactly as in Python — 10^0 through 10^30. Index 11 and above
274/// is a `KeyError`; see quirk 6 in the module docs. The trailing `bool` is
275/// read as `SCALE[chunk_len][-1]` to pick the gender of the unit word, and is
276/// never a `pluralize` output (`pluralize` only ever returns index 0, 1 or 2).
277const SCALE: [(&str, &str, &str, bool); 11] = [
278    ("", "", "", false),
279    ("хиљада", "хиљаде", "хиљада", true), // 10^3
280    ("милион", "милиона", "милиона", false), // 10^6
281    ("милијарда", "милијарде", "милијарди", true), // 10^9 - long scale
282    ("билион", "билиона", "билиона", false), // 10^12
283    ("билијарда", "билијарде", "билијарди", true), // 10^15
284    ("трилион", "трилиона", "трилиона", false), // 10^18
285    ("трилијарда", "трилијарде", "трилијарди", true), // 10^21
286    ("квадрилион", "квадрилиона", "квадрилиона", false), // 10^24
287    ("квадрилијарда", "квадрилијарде", "квадрилијарди", true), // 10^27
288    ("квинтилион", "квинтилиона", "квинтилиона", false), // 10^30
289];
290
291/// The `ordinals` dict local to `to_ordinal`, in Python's insertion order.
292///
293/// Note the gaps: 21..=29, 31..=39, ..., and everything above 100 except
294/// 1000, are absent and fall through to the "и"-suffix path.
295const ORDINALS: [(i64, &str); 30] = [
296    (1, "први"),
297    (2, "други"),
298    (3, "трећи"),
299    (4, "четврти"),
300    (5, "пети"),
301    (6, "шести"),
302    (7, "седми"),
303    (8, "осми"),
304    (9, "девети"),
305    (10, "десети"),
306    (11, "једанаести"),
307    (12, "дванаести"),
308    (13, "тринаести"),
309    (14, "четрнаести"),
310    (15, "петнаести"),
311    (16, "шеснаести"),
312    (17, "седамнаести"),
313    (18, "осамнаести"),
314    (19, "деветнаести"),
315    (20, "двадесети"),
316    (30, "тридесети"),
317    (40, "четрдесети"),
318    (50, "педесети"),
319    (60, "шездесети"),
320    (70, "седамдесети"),
321    (80, "осамдесети"),
322    (90, "деведесети"),
323    (100, "стоти"),
324    (1000, "хиљадити"),
325    (0, ""), // padding; never matched — see `ordinal_word`
326];
327
328/// Python's `num in ordinals` → `ordinals[num]`.
329///
330/// The `(0, "")` padding row in [`ORDINALS`] is skipped explicitly: 0 is *not*
331/// a key in Python's dict (`to_ordinal(0)` == "нулаи", not ""), so matching it
332/// would be a behaviour change.
333fn ordinal_word(num: &BigInt) -> Option<&'static str> {
334    let n = num.to_i64()?;
335    if n == 0 {
336        return None;
337    }
338    ORDINALS
339        .iter()
340        .find(|(k, _)| *k == n)
341        .map(|(_, w)| *w)
342}
343
344/// `SCALE[idx]`. Missing keys are Python's `KeyError`; see quirk 6.
345fn scale_at(idx: usize) -> Result<&'static (&'static str, &'static str, &'static str, bool)> {
346    SCALE
347        .get(idx)
348        .ok_or_else(|| N2WError::Key(idx.to_string()))
349}
350
351/// Port of `utils.splitbyx(n, x)` with `format_int=True`.
352///
353/// `n` is always `str(abs(number))` here — `_int2word` takes `abs()` before
354/// chunking — so every chunk is a run of 1..=3 ASCII digits and therefore
355/// fits `u32` (max 999). The head chunk `n[:start]` is 1 or 2 digits (max 99).
356/// This is why SR, unlike PL, has no `int("-")` `ValueError` hazard.
357///
358/// The `parse` failure is mapped to Python's `int()` `ValueError` for
359/// completeness; the invariant above means it is unreachable in practice.
360fn splitbyx(n: &str, x: usize) -> Result<Vec<u32>> {
361    let chars: Vec<char> = n.chars().collect();
362    let length = chars.len();
363    let parse = |s: String| -> Result<u32> {
364        s.parse::<u32>().map_err(|_| {
365            N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
366        })
367    };
368
369    let mut out: Vec<u32> = Vec::new();
370    if length > x {
371        let start = length % x;
372        if start > 0 {
373            out.push(parse(chars[..start].iter().collect())?);
374        }
375        let mut i = start;
376        while i < length {
377            let end = (i + x).min(length);
378            out.push(parse(chars[i..end].iter().collect())?);
379            i += x;
380        }
381    } else {
382        out.push(parse(n.to_string())?);
383    }
384    Ok(out)
385}
386
387/// Port of `utils.get_digits(n)`: `[int(x) for x in reversed(("%03d" % n)[-3:])]`.
388///
389/// Returns `[units, tens, hundreds]` — Python unpacks it as
390/// `digit_right, digit_mid, digit_left`. `n <= 999` (see [`splitbyx`]), so
391/// `"%03d"` yields exactly 3 digits and the `[-3:]` slice is total.
392fn get_digits(n: u32) -> [usize; 3] {
393    let s = format!("{:03}", n);
394    let chars: Vec<char> = s.chars().collect();
395    let tail = &chars[chars.len() - 3..];
396    let mut a = [0usize; 3];
397    for (k, c) in tail.iter().rev().enumerate() {
398        a[k] = c.to_digit(10).unwrap_or(0) as usize;
399    }
400    a
401}
402
403/// Port of `Num2Word_SR.pluralize(number, forms)`.
404///
405/// `forms` is a `SCALE` row; only indices 0..=2 are ever selected, so the
406/// trailing gender flag is never returned. Reproduces quirk 3: a remainder of
407/// exactly 10 is *not* `< 10` and *not* `> 20`, so it takes form 2.
408///
409/// `number` is a non-negative chunk, so Rust's `%` matches Python's here.
410fn pluralize(
411    number: u32,
412    forms: &'static (&'static str, &'static str, &'static str, bool),
413) -> &'static str {
414    let form = if number % 100 < 10 || number % 100 > 20 {
415        if number % 10 == 1 {
416            0
417        } else if 1 < number % 10 && number % 10 < 5 {
418            1
419        } else {
420            2
421        }
422    } else {
423        2
424    };
425    match form {
426        0 => forms.0,
427        1 => forms.1,
428        _ => forms.2,
429    }
430}
431
432/// Python's `str(number)` for a float/Decimal input — the string
433/// `Num2Word_SR.to_cardinal` reads (`n = str(number).replace(",", ".")`).
434///
435/// * **float** — `repr(float)`. Fixed notation (`{:.*}` at the shim's
436///   repr-derived precision, so `str(Decimal("1.10"))`-style trailing zeros
437///   and `-0.0`'s sign survive) whenever Python's repr shows a decimal
438///   point; otherwise repr picked exponent form (`abs(v) >= 1e16`:
439///   "1e+16"), reconstructed by [`python_float_exp_repr`]. The exponent form
440///   is load-bearing: it has no ".", so `to_cardinal` falls through to
441///   `int("1e+16")` and raises ValueError — corpus-pinned, while
442///   `to_ordinal(1e+16)` (which `int()`s the *value*) succeeds.
443/// * **Decimal** — [`python_decimal_str`], the spec `__str__`: trailing
444///   zeros kept ("5.00" -> right "00", two leading zeros *plus*
445///   `int("00") == 0`, hence the tripled "нула"), scientific form for
446///   positive exponents ("1E+2", "1E+20" — again a ValueError from `int()`).
447///
448/// Known gap (unpinned): a float in `(0, 1e-4)` also reprs in exponent form
449/// ("1e-05"), but `FloatValue::has_visible_point` reports `true` for every
450/// finite fractional float, so such values take the fixed-notation arm here
451/// and render instead of raising. No `sr` corpus row reaches that range.
452fn python_number_str(v: &FloatValue) -> String {
453    match v {
454        FloatValue::Float { value, precision } => {
455            if v.has_visible_point() {
456                format!("{:.*}", *precision as usize, value)
457            } else {
458                python_float_exp_repr(*value)
459            }
460        }
461        FloatValue::Decimal { value, .. } => python_decimal_str(value),
462    }
463}
464
465/// Python's `repr(float)` for the exponent-form cases: shortest mantissa
466/// (Rust's `{:e}` is shortest-round-trip, the same contract), "e", an
467/// explicit sign and a >= 2-digit exponent — `1e16` -> "1e+16", `1.5e20` ->
468/// "1.5e+20". Non-finite values print as Python does ("inf"/"-inf"/"nan");
469/// `int()` on those strings raises the same ValueError as on "1e+16".
470fn python_float_exp_repr(v: f64) -> String {
471    if v.is_nan() {
472        return "nan".to_string();
473    }
474    if v.is_infinite() {
475        return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
476    }
477    let s = format!("{:e}", v);
478    match s.split_once('e') {
479        Some((mant, exp)) => {
480            let e: i64 = exp.parse().unwrap_or(0);
481            let sign = if e < 0 { "-" } else { "+" };
482            format!("{}e{}{:02}", mant, sign, e.abs())
483        }
484        None => s,
485    }
486}
487
488/// Extract SR's `feminine=` kwarg. Python's signature is
489/// `to_cardinal(self, number, feminine=False)`; the flag only ever feeds
490/// `feminine or SCALE[chunk_len][-1]` followed by `int(is_feminine)`, so:
491///
492/// * absent / `False` / explicit `None` (falsy) -> masculine column;
493/// * `True` -> feminine column;
494/// * any *other* key, or a non-bool value (`feminine=2` would `int()` to a
495///   tuple index Python crashes on) -> `NotImplemented`, so the dispatcher
496///   falls back to the original Python and its genuine semantics.
497fn feminine_kwarg(kw: &Kwargs) -> Result<bool> {
498    if !kw.only(&["feminine"]) {
499        return Err(N2WError::Fallback("kwargs".into()));
500    }
501    match kw.get("feminine") {
502        Option::None | Some(KwVal::None) => Ok(false),
503        Some(KwVal::Bool(b)) => Ok(*b),
504        Some(_) => Err(N2WError::Fallback("kwargs".into())),
505    }
506}
507
508/// `Num2Word_SR.CURRENCY_FORMS`, verbatim — RUB, EUR, RSD and nothing else.
509///
510/// # Why the gender flag is a `"False"`/`"True"` string
511///
512/// Python's entries are pairs of **4-tuples**: `("dinar", "dinara", "dinara",
513/// False)`. That trailing element is a `bool`, and it is read from two places
514/// that want two different things out of it:
515///
516/// * `Num2Word_SR._cents_verbose` reads `CURRENCY_FORMS[cur][1][-1]` and uses
517///   it as the `feminine` argument — its intended purpose.
518/// * `Num2Word_Base.to_cheque` reads `cr1[-1]` believing it is the plural unit
519///   name, and interpolates it into a `"%s"` — quirk 11. Python renders the
520///   bool as the text `False`/`True`, which `.upper()` then shouts.
521///
522/// `CurrencyForms` stores `Vec<String>`, so keeping the flag as the exact text
523/// Python's `"%s"` produces reproduces the cheque bug through the *unmodified*
524/// `currency::default_to_cheque` (which takes `forms.unit.last()`), while
525/// [`LangSr::cents_verbose`] recovers the boolean by comparing against
526/// `"True"`. Storing a real `bool` would need a parallel table and a
527/// `to_cheque` override to reprint it — more code, same bytes.
528///
529/// The arity is load-bearing beyond that: [`LangSr::pluralize`] indexes 0..=2,
530/// so dropping the third form would silently change output.
531fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
532    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
533    m.insert(
534        "RUB",
535        CurrencyForms::new(
536            &["rublja", "rublje", "rublji", "True"],
537            &["kopejka", "kopejke", "kopejki", "True"],
538        ),
539    );
540    m.insert(
541        "EUR",
542        CurrencyForms::new(
543            &["evro", "evra", "evra", "False"],
544            &["cent", "centa", "centi", "False"],
545        ),
546    );
547    m.insert(
548        "RSD",
549        CurrencyForms::new(
550            &["dinar", "dinara", "dinara", "False"],
551            &["para", "pare", "para", "True"],
552        ),
553    );
554    m
555}
556
557pub struct LangSr {
558    /// Built once in [`LangSr::new`] and only ever read. `Num2Word_SR` reads
559    /// `CURRENCY_FORMS` as a class attribute, so rebuilding it per call would
560    /// be both wrong in spirit and measurably slower than the Python.
561    currency_forms: HashMap<&'static str, CurrencyForms>,
562}
563
564impl Default for LangSr {
565    fn default() -> Self {
566        Self::new()
567    }
568}
569
570impl LangSr {
571    pub fn new() -> Self {
572        LangSr {
573            currency_forms: build_currency_forms(),
574        }
575    }
576
577    /// Port of `Num2Word_SR._int2word(number, feminine=False)`.
578    ///
579    /// **`SCALE` access order is load-bearing** (quirk 6). Python touches
580    /// `SCALE` in exactly two places per chunk, and both are conditional:
581    ///
582    /// 1. `SCALE[chunk_len][-1]`, only inside the `elif digit_right > 0`
583    ///    branch *and* only when the "skip један" test lets it through. So a
584    ///    chunk whose tens digit is 1 (the `if digit_mid == 1` arm) never
585    ///    reaches it.
586    /// 2. `pluralize(chunk, SCALE[chunk_len])`, only when
587    ///    `chunk_len > 0 and chunk != 0`.
588    ///
589    /// In practice the ceiling is sharp at 10^33: the leading chunk of
590    /// `str(number)` is never zero, so the highest chunk index always clears
591    /// the `chunk != 0` guard of access 2 and raises. (Verified: 3000 random
592    /// values in 10^33..10^45 all raise, including ones whose leading chunk
593    /// takes the `digit_mid == 1` arm and thus skips access 1.) The guards are
594    /// still mirrored literally rather than hoisted into an up-front range
595    /// check, so that *where* the `KeyError` fires matches Python exactly.
596    fn int2word(&self, number: &BigInt, feminine: bool) -> Result<String> {
597        if number.is_negative() {
598            // Python: " ".join([self.negword, self._int2word(abs(number))])
599            // `feminine` is NOT forwarded — quirk 5, reproduced verbatim.
600            return Ok(format!("{} {}", NEGWORD, self.int2word(&number.abs(), false)?));
601        }
602
603        if number.is_zero() {
604            return Ok(ZERO.to_string());
605        }
606
607        let mut words: Vec<&str> = Vec::new();
608        let chunks = splitbyx(&number.to_string(), 3)?;
609        let mut chunk_len = chunks.len();
610
611        for chunk in chunks {
612            // `chunks` is non-empty, so this never underflows.
613            chunk_len -= 1;
614            let [digit_right, digit_mid, digit_left] = get_digits(chunk);
615
616            if digit_left > 0 {
617                words.push(HUNDREDS[digit_left]);
618            }
619
620            if digit_mid > 1 {
621                words.push(TWENTIES[digit_mid]);
622            }
623
624            if digit_mid == 1 {
625                words.push(TENS[digit_right]);
626            } else if digit_right > 0 {
627                // Python: `if not (chunk_len > 0 and chunk == 1)` — the test is
628                // on the whole chunk, not the digit (quirk 4).
629                if !(chunk_len > 0 && chunk == 1) {
630                    let is_feminine = feminine || scale_at(chunk_len)?.3;
631                    let gender_idx = usize::from(is_feminine);
632                    let ones = ONES[digit_right];
633                    words.push(if gender_idx == 0 { ones.0 } else { ones.1 });
634                }
635            }
636
637            if chunk_len > 0 && chunk != 0 {
638                words.push(pluralize(chunk, scale_at(chunk_len)?));
639            }
640        }
641
642        Ok(words.join(" "))
643    }
644
645    /// Port of `Num2Word_SR.to_cardinal(number, feminine=False)` for a
646    /// float/Decimal `number` — the whole method, both branches:
647    ///
648    /// ```python
649    /// n = str(number).replace(",", ".")
650    /// if "." in n:
651    ///     is_negative = n.startswith("-")
652    ///     abs_n = n[1:] if is_negative else n
653    ///     left, right = abs_n.split(".")
654    ///     leading_zero_count = len(right) - len(right.lstrip("0"))
655    ///     decimal_part = (ZERO[0] + " ") * leading_zero_count \
656    ///         + self._int2word(int(right), feminine)
657    ///     result = "%s %s %s" % (self._int2word(int(left), feminine),
658    ///                            self.pointword, decimal_part)
659    ///     if is_negative:
660    ///         result = self.negword + " " + result
661    ///     return result
662    /// else:
663    ///     return self._int2word(int(n), feminine)
664    /// ```
665    ///
666    /// Two properties set SR apart from the inherited digit-by-digit float
667    /// path and are load-bearing:
668    ///
669    /// 1. **The fraction is one whole number, not a digit sequence.**
670    ///    `int(right)` is fed to `_int2word`, so `2.675` -> "два запета
671    ///    шесто седамдесет пет" (675 as a number). SR never touches
672    ///    `float2tuple`, so the f64-artefact `< 0.01` heuristic is
673    ///    irrelevant: it reads `repr(2.675)` == "2.675" and parses "675".
674    /// 2. **Leading fraction zeros become "нула" words.** `0.01` splits to
675    ///    `right == "01"`, one leading zero, so `int("01") == 1` is prefixed
676    ///    by a single "нула": "нула запета нула један". `right == "0"`
677    ///    (from `1.0`) counts as one leading zero *and* `int("0") == 0` ->
678    ///    "нула", giving the doubled "нула нула".
679    ///
680    /// The `else` arm is where the exponent-form strings die: `int("1e+16")`
681    /// / `int("1E+2")` raise ValueError with Python's exact message.
682    ///
683    /// `feminine` comes from the kwarg surface (default `False`); the
684    /// negative recursion inside `int2word` still drops it (quirk 5).
685    fn cardinal_float_str(&self, value: &FloatValue, feminine: bool) -> Result<String> {
686        // n = str(number).replace(",", "."). The reconstruction never emits a
687        // comma, so the replace is a no-op; the sign is kept on the string so
688        // the `startswith("-")` test below matches Python byte for byte.
689        let n = python_number_str(value);
690
691        // Python's int() on a token of str(number). Reachable failures are
692        // the exponent forms ("1e+16", "1E+2", "inf") — exactly where Python
693        // raises ValueError, message included.
694        let to_int = |s: &str| -> Result<BigInt> {
695            s.parse::<BigInt>().map_err(|_| {
696                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
697            })
698        };
699
700        match n.find('.') {
701            Some(_) => {
702                let is_negative = n.starts_with('-');
703                // abs_n = n[1:] if is_negative else n. The "-" is one ASCII byte.
704                let abs_n = if is_negative { &n[1..] } else { &n[..] };
705                // left, right = abs_n.split("."). Exactly one "." is present.
706                let (left, right) = abs_n.split_once('.').unwrap();
707
708                // leading_zero_count = len(right) - len(right.lstrip("0")). A
709                // fully-zero `right` counts every char, matching lstrip("0")=="".
710                let leading_zero_count = right.chars().take_while(|&c| c == '0').count();
711
712                // decimal_part = (ZERO[0] + " ") * leading_zero_count
713                //                + self._int2word(int(right), feminine)
714                let mut decimal_part = format!("{} ", ZERO).repeat(leading_zero_count);
715                decimal_part.push_str(&self.int2word(&to_int(right)?, feminine)?);
716
717                // result = "%s %s %s" % (_int2word(int(left), feminine),
718                //                        pointword, decimal_part)
719                let mut result = format!(
720                    "{} {} {}",
721                    self.int2word(&to_int(left)?, feminine)?,
722                    POINTWORD,
723                    decimal_part
724                );
725
726                // if is_negative: result = self.negword + " " + result
727                if is_negative {
728                    result = format!("{} {}", NEGWORD, result);
729                }
730                Ok(result)
731            }
732            None => {
733                // else: return self._int2word(int(n), feminine). int(n) keeps
734                // the sign, and int2word renders "минус ..." for a negative.
735                self.int2word(&to_int(&n)?, feminine)
736            }
737        }
738    }
739}
740
741impl Lang for LangSr {
742    /// This language's own `to_currency(currency=...)` default,
743    /// read from the live Python signature. Only 44 of 156 use EUR.
744    fn default_currency(&self) -> &str {
745        "RSD"
746    }
747
748    /// This language's own `to_currency(separator=...)` default,
749    /// read from the live Python signature. Base's is ",", but only
750    /// 36 of 149 languages actually use it — most default to " " or a
751    /// conjunction, so inheriting Base's comma silently corrupts them.
752    fn default_separator(&self) -> &str {
753        ","
754    }
755
756    fn negword(&self) -> &str {
757        NEGWORD
758    }
759
760    fn pointword(&self) -> &str {
761        "запета"
762    }
763
764    /// Port of `Num2Word_SR.to_cardinal(number, feminine=False)`, integral arm.
765    ///
766    /// A plain call never passes `feminine=True` (the currency path does via
767    /// `_cents_verbose`, and the kwarg surface via `to_cardinal_kw`), so this
768    /// delegates with `feminine = false`. The float/Decimal branch lives in
769    /// [`LangSr::cardinal_float_str`].
770    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
771        self.int2word(value, false)
772    }
773
774    /// Port of `Num2Word_SR.to_ordinal(number)`.
775    ///
776    /// Python's `try: num = int(number) / except (ValueError, TypeError):
777    /// return str(number)` guard cannot trigger for a `BigInt`, so it is
778    /// omitted. Everything outside the small `ordinals` table gets the naive
779    /// "и" suffix — quirks 1 and 2.
780    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
781        if let Some(word) = ordinal_word(value) {
782            return Ok(word.to_string());
783        }
784        // Propagates the SCALE KeyError for huge inputs, as Python does.
785        let cardinal = self.to_cardinal(value)?;
786        Ok(format!("{}и", cardinal))
787    }
788
789    /// The raw float grammar — [`LangSr::cardinal_float_str`] with
790    /// `feminine=False`.
791    ///
792    /// `precision_override` (the base's issue-#580 `precision=` kwarg) is
793    /// **not** a parameter of `Num2Word_SR.to_cardinal`, which reads
794    /// `str(number)` and consults no per-language precision, so it is ignored
795    /// — matching Python, where setting `converter.precision` leaves this
796    /// method's output untouched. This hook also serves the fractional-cents
797    /// currency path (`cardinal_from_decimal` -> `cardinal_from_bigdecimal`
798    /// dispatches through the trait), mirroring Python's virtual
799    /// `self.to_cardinal(float(right))`.
800    fn to_cardinal_float(
801        &self,
802        value: &FloatValue,
803        _precision_override: Option<u32>,
804    ) -> Result<String> {
805        self.cardinal_float_str(value, false)
806    }
807
808    /// `to_cardinal(float/Decimal)` — the FULL entry, whole values included.
809    ///
810    /// SR overrides `to_cardinal` itself and branches on `"." in str(number)`,
811    /// so a whole value with a visible point still takes the float grammar:
812    /// `5.0` -> "пет запета нула нула", `Decimal("5.00")` -> "пет запета нула
813    /// нула нула", `-0.0` -> "минус нула запета нула нула". A pointless string
814    /// falls to `int(n)`: `Decimal("5")` -> "пет", but `1e+16` / `Decimal("1E+2")`
815    /// raise ValueError. The base default (whole -> int path) would get every
816    /// one of those wrong, hence this override.
817    fn cardinal_float_entry(
818        &self,
819        value: &FloatValue,
820        _precision_override: Option<u32>,
821    ) -> Result<String> {
822        self.cardinal_float_str(value, false)
823    }
824
825    /// `to_ordinal(float/Decimal)`. Python's `to_ordinal` opens with
826    /// `num = int(number)` — `int()` of the *value*, truncation toward zero —
827    /// so `2.5` -> 2 -> "други", `-1.5` -> -1 -> "минус једани", and `1e+16`
828    /// *succeeds* ("десет билијардии") where the cardinal raises ValueError.
829    ///
830    /// The `except (ValueError, TypeError): return str(number)` guard can
831    /// only fire here for `nan` (`int(nan)` is ValueError, so Python returns
832    /// `str(nan)` == "nan"); `int(inf)` raises OverflowError, which the guard
833    /// does **not** catch. Neither case has a corpus row; both are mirrored
834    /// for completeness.
835    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
836        let num = match value {
837            FloatValue::Float { value: f, .. } => {
838                if f.is_nan() {
839                    return Ok("nan".to_string());
840                }
841                BigInt::from_f64(f.trunc()).ok_or_else(|| {
842                    N2WError::Overflow("cannot convert float infinity to integer".into())
843                })?
844            }
845            // int(Decimal) truncates toward zero; with_scale(0) drops the
846            // fractional digits the same way (see floatpath::float2tuple).
847            FloatValue::Decimal { value: d, .. } => d.with_scale(0).as_bigint_and_exponent().0,
848        };
849        self.to_ordinal(&num)
850    }
851
852    // `year_float_entry` is deliberately NOT overridden: Base's `to_year` is
853    // `self.to_cardinal(value)`, and the trait default routes through the
854    // overridden `cardinal_float_entry` above — so `to_year(5.0)` == "пет
855    // запета нула нула" and `to_year(1e+16)` raises ValueError, as pinned.
856    // `ordinal_num_float_entry` stays at the default too: SR never defines
857    // `to_ordinal_num`, and the dispatcher's getattr fallback echoes
858    // `str(value)` — exactly the default's repr echo.
859
860    /// `converter.str_to_number` — Base's `Decimal(value)`, which SR does not
861    /// override. The `Inf` interception reproduces what happens *next* on the
862    /// pinned path: `to_cardinal(Decimal("Infinity"))` reads `n = str(number)`
863    /// == "Infinity", finds no ".", and dies in `int("Infinity")` with
864    /// ValueError. The binding otherwise maps `ParsedNumber::Inf` to the base
865    /// integer path's OverflowError before any SR code runs, so the
866    /// ValueError must be raised here. (NaN needs no interception: the
867    /// binding's ValueError already matches `int("NaN")`'s type.)
868    ///
869    /// Known gap (unpinned): Python's `to_ordinal(Decimal("Infinity"))`
870    /// raises OverflowError (`int()` of the Decimal *object*), which this
871    /// entry-level interception turns into the cardinal path's ValueError.
872    /// The strings corpus pins Infinity under `to=cardinal` only.
873    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
874        match python_decimal_parse(s)? {
875            ParsedNumber::Inf { negative } => Err(N2WError::Value(format!(
876                "invalid literal for int() with base 10: '{}Infinity'",
877                if negative { "-" } else { "" }
878            ))),
879            other => Ok(other),
880        }
881    }
882
883    // ---- grammatical kwargs ----------------------------------------------
884
885    /// `to_cardinal(number, feminine=...)` — the only SR signature with an
886    /// extra kwarg. `feminine=True` picks the feminine `ONES` column:
887    /// "једна", "две", "двадесет једна". The negative recursion still drops
888    /// the flag (quirk 5): `to_cardinal(-5, feminine=True)` == "минус пет".
889    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
890        let feminine = feminine_kwarg(kw)?;
891        self.int2word(value, feminine)
892    }
893
894    /// The float/Decimal side of the same kwarg: Python's `to_cardinal`
895    /// threads `feminine` into *both* `_int2word` calls of the "." branch.
896    fn to_cardinal_float_kw(
897        &self,
898        value: &FloatValue,
899        _precision_override: Option<u32>,
900        kw: &Kwargs,
901    ) -> Result<String> {
902        let feminine = feminine_kwarg(kw)?;
903        self.cardinal_float_str(value, feminine)
904    }
905
906    // ---- currency -------------------------------------------------------
907    //
908    // `Num2Word_SR` overrides exactly three things on this surface —
909    // `CURRENCY_FORMS`, `pluralize` and `_cents_verbose` — plus `to_currency`
910    // for its int shortcut. Everything else (`_money_verbose`, `_cents_terse`,
911    // `to_cheque`, and the whole float path) is `Num2Word_Base`'s, which the
912    // trait defaults already mirror, so it is deliberately not overridden:
913    //
914    //   * `currency_adjective` — `CURRENCY_ADJECTIVES` is `{}`; default `None`
915    //     is right, and `adjective=True` is a silent no-op exactly as in
916    //     Python (`if adjective and currency in self.CURRENCY_ADJECTIVES`).
917    //   * `currency_precision` — `CURRENCY_PRECISION` is `{}`; every code takes
918    //     the `.get(code, 100)` default of 100.
919    //   * `money_verbose` — Base's `self.to_cardinal(number)`, i.e. masculine
920    //     forms regardless of the unit's gender flag (quirk 10).
921    //   * `cents_terse` — Base's `"%0*d"`, width `len("100") - 1` == 2.
922    //   * `to_cheque` — Base's, bool-as-unit-name bug and all (quirk 11).
923
924    fn lang_name(&self) -> &str {
925        "Num2Word_SR"
926    }
927
928    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
929        self.currency_forms.get(code)
930    }
931
932    /// Port of `Num2Word_SR.pluralize(number, forms)` for the currency path.
933    ///
934    /// Identical rule to the private [`pluralize`] free function above, which
935    /// serves `_int2word`'s `SCALE` rows; Python has one method doing both
936    /// jobs, but the two call sites carry different types (`u32` + a `SCALE`
937    /// tuple vs. `BigInt` + a `CURRENCY_FORMS` tuple) and the `_int2word` path
938    /// is already verified against the corpus, so it is left untouched rather
939    /// than generalised underneath it.
940    ///
941    /// Reproduces quirk 3 here too: `number % 100 == 10` is neither `< 10` nor
942    /// `> 20`, so it falls to form 2 ("десет evra", not "десет evro").
943    ///
944    /// `mod_floor` rather than `%`: Python's `%` floors on negatives. `left`
945    /// and `right` both arrive non-negative from `parse_currency_parts`, so
946    /// the two agree in practice, but the port should not depend on that.
947    ///
948    /// Python indexes `forms[form]` directly, so a table entry with fewer than
949    /// three forms would raise IndexError. All three of SR's entries carry four
950    /// elements, so this is unreachable — mapped rather than panicking so the
951    /// exception type survives if the table ever changes.
952    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
953        let ten = BigInt::from(10);
954        let m100 = n.mod_floor(&BigInt::from(100));
955        let m10 = n.mod_floor(&ten);
956
957        let form = if m100 < ten || m100 > BigInt::from(20) {
958            if m10.is_one() {
959                0
960            } else if m10 > BigInt::one() && m10 < BigInt::from(5) {
961                1
962            } else {
963                2
964            }
965        } else {
966            2
967        };
968        forms
969            .get(form)
970            .cloned()
971            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
972    }
973
974    /// Port of `Num2Word_SR._cents_verbose(number, currency)`:
975    /// `self._int2word(number, self.CURRENCY_FORMS[currency][1][-1])`.
976    ///
977    /// Note it calls `_int2word` directly rather than `to_cardinal`, and that
978    /// the gender flag is the subunit tuple's trailing element — so RSD's
979    /// `para` (flag `True`) gets feminine numerals ("једна para", "две pare")
980    /// while EUR's `cent` (flag `False`) stays masculine ("један cent").
981    ///
982    /// The `CURRENCY_FORMS[currency]` lookup is Python's `KeyError`, but it is
983    /// unreachable: `Num2Word_Base.to_currency` resolves `cr1, cr2` from the
984    /// same dict, and raises `NotImplementedError` on a miss, before this can
985    /// run. Same story for the `[-1]` IndexError on an empty tuple.
986    fn cents_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
987        let forms = self
988            .currency_forms
989            .get(currency)
990            .ok_or_else(|| N2WError::Key(format!("'{}'", currency)))?;
991        let flag = forms
992            .subunit
993            .last()
994            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
995        // The flag is stored as Python's own `"%s" % bool` text; see
996        // `build_currency_forms` for why.
997        self.int2word(number, flag.as_str() == "True")
998    }
999
1000    /// Port of `Num2Word_SR.to_currency(val, currency="RSD", cents=True,
1001    /// separator=",", adjective=False)`.
1002    ///
1003    /// Only the `isinstance(val, int)` shortcut is SR's own; every other value
1004    /// is handed to `super().to_currency(...)` unchanged. That shortcut never
1005    /// touches `CURRENCY_FORMS`, so it neither honours `currency=` nor raises
1006    /// for an unknown code — quirk 8.
1007    fn to_currency(
1008        &self,
1009        val: &CurrencyValue,
1010        currency: &str,
1011        cents: bool,
1012        separator: Option<&str>,
1013        adjective: bool,
1014    ) -> Result<String> {
1015        // `None` means the caller omitted `separator=`, so SR's own default
1016        // (Base's ",", per the live signature) applies. Python passes whatever
1017        // it resolved straight through to `super().to_currency`.
1018        let separator = separator.unwrap_or(self.default_separator());
1019
1020        if let CurrencyValue::Int(_) = val {
1021            // parse_currency_parts(val, is_int_with_cents=False). `right` is
1022            // always 0 on this path and Python discards it; the divisor is
1023            // likewise unused, so 100 just mirrors the Python default.
1024            let (left, _right, is_negative) = parse_currency_parts(val, false, false, 100);
1025
1026            let mut words: Vec<String> = Vec::new();
1027            if is_negative {
1028                // Python hardcodes the literal here rather than reading
1029                // `self.negword`. Same text, but kept distinct on purpose.
1030                words.push("минус".to_string());
1031            }
1032            // `left` is already `abs(val)`, so this is `self.to_cardinal(left)`
1033            // with `feminine=False` — the masculine "један динар", never "једна".
1034            words.push(self.to_cardinal(&left)?);
1035
1036            // Python:
1037            //     if left % 10 == 1 and left % 100 != 11:   -> "динар"
1038            //     elif 2 <= left % 10 <= 4 and not (12 <= left % 100 <= 14):
1039            //                                               -> "динара"
1040            //     else:                                     -> "динара"
1041            // The elif and the else emit the same word, so the elif cannot
1042            // affect the result and is collapsed here (quirk 9). `left` is
1043            // non-negative, so `%` and `mod_floor` agree; `mod_floor` is used
1044            // to match Python's operator rather than rely on that.
1045            let unit = if left.mod_floor(&BigInt::from(10)).is_one()
1046                && left.mod_floor(&BigInt::from(100)) != BigInt::from(11)
1047            {
1048                "динар"
1049            } else {
1050                "динара"
1051            };
1052            words.push(unit.to_string());
1053
1054            return Ok(words.join(" "));
1055        }
1056
1057        // Floats/Decimals: `super().to_currency(...)`, which is where an
1058        // unknown code finally raises NotImplementedError.
1059        default_to_currency(self, val, currency, cents, separator, adjective)
1060    }
1061}