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}