Skip to main content

num2words2_core/
lang_so.rs

1//! Port of `lang_SO.py` (Somali).
2//!
3//! Registry check: `CONVERTER_CLASSES["so"]` is `lang_SO.Num2Word_SO()`, which
4//! is the class ported here.
5//!
6//! Shape: **self-contained**. `Num2Word_SO` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`. `Num2Word_Base.
8//! __init__` only builds `self.cards`/`self.MAXVAL` when one of those three
9//! attributes exists, so for Somali it builds **neither**. `to_cardinal` is
10//! overridden outright and drives a private `_int_to_word` recursion; `cards`,
11//! `maxval` and `merge` therefore stay at their trait defaults here and are
12//! never consulted. There is **no overflow check** at any size — see bug 2.
13//!
14//! Inheritance chain is exactly `Num2Word_SO -> Num2Word_Base -> object`. All
15//! four in-scope modes are overridden by `Num2Word_SO`, so nothing in this file
16//! depends on inherited behaviour:
17//!   * `to_cardinal`    — overridden (string sign-strip + `_int_to_word`)
18//!   * `to_ordinal`     — overridden (`to_cardinal` + "-aad")
19//!   * `to_ordinal_num` — overridden (`str(number)` + ".")
20//!   * `to_year`        — overridden (delegates to `to_cardinal`, ignoring
21//!     its own `longval=True` parameter entirely)
22//!
23//! `setup()` also sets `pointword = "point"`, used only by the float branch of
24//! `to_cardinal`, which is out of scope (integer input only) and unreachable
25//! here: an integer's decimal repr never contains ".".
26//!
27//! ## Currency surface
28//!
29//! `Num2Word_SO` defines `CURRENCY_FORMS` (SOS/USD/EUR) and overrides
30//! `to_currency` **wholesale** — it shares no code with
31//! `Num2Word_Base.to_currency`: no `parse_currency_parts`, no `pluralize`, no
32//! `CURRENCY_PRECISION`, no `isinstance(val, int)` branch. It does *not*
33//! override `to_cheque`, so cheques run `Num2Word_Base.to_cheque`
34//! (`currency::default_to_cheque`) against the same forms table. That split is
35//! the source of quirk 7 below: an unknown code prints happily via
36//! `to_currency` but raises via `to_cheque`.
37//!
38//! No cross-call mutable state: `Num2Word_SO` sets no flags in one method for
39//! another to consume, so the stateless Rust path is a faithful substitute.
40//!
41//! # Faithfully reproduced Python bugs / oddities
42//!
43//! This is a port, not a rewrite. Everything below looks wrong but is exactly
44//! what Python emits, and every item is confirmed against the frozen corpus:
45//!
46//! 1. **Zero is the English word "zero".** `setup` makes `ones[0]` the empty
47//!    string, and `_int_to_word` opens with
48//!    `return self.ones[0] if self.ones[0] else "zero"`. `""` is falsy, so the
49//!    guard always takes the `else`: the table's own entry for 0 is dead and
50//!    Somali emits English "zero". Hence `to_cardinal(0)` == "zero" and
51//!    `to_ordinal(0)` == "zero-aad". (Contrast `lang_PL`, where `to_ordinal(0)`
52//!    crashes — Somali does not crash, it just answers in English.)
53//! 2. **Numbers >= 10^9 come back as digits, not words.** `_int_to_word`'s
54//!    final `else` is `return str(number)  # Fallback for very large numbers`.
55//!    There is no `MAXVAL` and no `OverflowError` — the function silently
56//!    degrades. So `to_cardinal(10**9)` == "1000000000",
57//!    `to_ordinal(10**9)` == "1000000000-aad", and `to_cardinal(10**21)` ==
58//!    "1000000000000000000000". All four are corpus rows. This is why
59//!    [`LangSo::int_to_word`] takes a `BigInt`: the fallback must render
60//!    arbitrarily large values, so the input is genuinely unbounded.
61//! 3. **The negword is the English "minus ".** Not a Somali word.
62//! 4. **Teens and compounds are bare juxtaposition.** 11 is "toban kow"
63//!    ("ten one"), not the idiomatic "kow iyo toban"; 100 is "kow boqol"
64//!    ("one hundred"), never a bare "boqol". No conjunction is ever inserted
65//!    at any magnitude. Reproduced verbatim.
66//! 5. **`_int_to_word`'s `number < 0` branch is dead code** on every in-scope
67//!    path. `to_cardinal` strips the "-" from the *string* before calling
68//!    `int()`, so `_int_to_word` only ever receives a non-negative value from
69//!    the four modes in scope. (Only the out-of-scope `to_currency` could reach
70//!    it, and it pre-`abs()`es too.) It is preserved in [`LangSo::int_to_word`]
71//!    for fidelity, with the same ordering as Python: the `== 0` test precedes
72//!    the `< 0` test.
73//! 6. **`to_ordinal_num` ignores the sign convention and the language.** It is
74//!    `str(number) + "."` — so `to_ordinal_num(-1)` == "-1." and
75//!    `to_ordinal_num(0)` == "0.". A trailing dot is the German/Polish ordinal
76//!    convention, not a Somali one, and "-aad" (used by `to_ordinal`) never
77//!    appears here.
78//! 7. **An unknown currency code never raises in `to_currency`** — it silently
79//!    becomes Somali shillings. See [`FALLBACK_CURRENCY`].
80//! 8. **Cents are read lexically and truncated, never rounded**, and
81//!    `CURRENCY_PRECISION` is ignored outright. See [`LangSo::to_currency`].
82//!
83//! No in-scope integer input raises: the corpus contains zero `ok: false` rows
84//! for cardinal/ordinal/ordinal_num/year, so those four modes return `Ok`
85//! unconditionally. The currency surface added here raises only where Python
86//! does: `to_cheque` on a code outside SOS/USD/EUR (`NotImplementedError`).
87
88use crate::base::{Lang, N2WError, Result};
89use crate::currency::{CurrencyForms, CurrencyValue};
90use crate::floatpath::FloatValue;
91use crate::strnum::{python_decimal_parse, ParsedNumber};
92use bigdecimal::BigDecimal;
93use num_bigint::BigInt;
94use num_traits::{Signed, ToPrimitive, Zero};
95use std::collections::HashMap;
96use std::str::FromStr;
97
98/// `setup`: `self.ones`. Index 0 is `""` and is unreachable — see bug 1.
99const ONES: [&str; 10] = [
100    "", "kow", "laba", "saddex", "afar", "shan", "lix", "toddoba", "siddeed", "sagaal",
101];
102
103/// `setup`: `self.tens`. Index 0 is `""` and is unreachable: the `< 100` branch
104/// is only entered for `number >= 10`, so `number // 10` is always 1..=9.
105const TENS: [&str; 10] = [
106    "",
107    "toban",
108    "labaatan",
109    "soddon",
110    "afartan",
111    "konton",
112    "lixdan",
113    "toddobaatan",
114    "siddeetan",
115    "sagaashan",
116];
117
118const HUNDRED: &str = "boqol";
119const THOUSAND: &str = "kun";
120const MILLION: &str = "milyan";
121
122/// `setup`: `self.negword`. English, and kept that way — see bug 3.
123const NEGWORD: &str = "minus ";
124
125/// The literal in `_int_to_word`'s zero guard — see bug 1.
126const ZERO_WORD: &str = "zero";
127
128/// `_int_to_word`'s `else` threshold: at or above this, Python returns
129/// `str(number)` rather than words (bug 2).
130const DIGIT_FALLBACK_FLOOR: u32 = 1_000_000_000;
131
132/// The class name, for `to_cheque`'s NotImplementedError message.
133const LANG_NAME: &str = "Num2Word_SO";
134
135/// Python: `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
136///
137/// `list(values())[0]` is the **first-inserted** entry. The class body lists
138/// `SOS` before `USD` before `EUR`, and dicts have preserved insertion order
139/// since CPython 3.7 — the live interpreter confirms
140/// `list(CURRENCY_FORMS.keys()) == ['SOS', 'USD', 'EUR']`. So every unknown
141/// code falls back to the Somali shilling rather than raising (bug 7). A
142/// `HashMap` has no order of its own, so the choice is pinned to a constant
143/// here rather than left to iteration order.
144const FALLBACK_CURRENCY: &str = "SOS";
145
146/// The separator the FFI bridge sends when the caller passed none.
147///
148/// `Num2Word_SO.to_currency` declares `separator=" "`, but the bridge cannot
149/// express "caller omitted it": `num2words2/__init__.py` (via
150/// `kwargs.get("separator", ",")`) and `bench/diff_test.py` both send
151/// `Num2Word_Base`'s default `","` literally. The corpus was generated through
152/// the Python path with `separator=` omitted, so every expected string uses
153/// `" "` — the 36 currency rows with a cents segment all depend on mapping this
154/// back.
155///
156/// Right for the default call and for every caller who passes anything other
157/// than `","`; wrong only for an explicit `separator=","`, which Python renders
158/// `"toban laba euros,soddon afar cents"` and this renders with a space. That
159/// one case is indistinguishable from the default at this boundary. Fixing it
160/// properly means teaching the binding each language's own default, which
161/// cannot be done from this file. ~100 of the 156 Python modules override this
162/// default, so the issue is systemic rather than SO-specific; the sibling ports
163/// (`lang_haw.rs`, `lang_gl.rs`, `lang_br.rs`) adopt the same mapping.
164/// Flagged in the port report.
165const SEPARATOR_UNSET: &str = ",";
166
167/// SO's own `to_currency` default, restored when [`SEPARATOR_UNSET`] arrives.
168const SEPARATOR_DEFAULT: &str = " ";
169
170pub struct LangSo {
171    /// `CURRENCY_FORMS`, built once in [`LangSo::new`] and cached by the caller
172    /// (`num2words2-py` holds this in a `OnceLock`), never per call.
173    ///
174    /// Every entry carries exactly two unit forms and two subunit forms,
175    /// matching Python's tuple arity — `to_currency` indexes `[0]`/`[1]`
176    /// directly and `default_to_cheque` takes `.last()`, so the arity is
177    /// load-bearing. SOS's two forms are deliberately identical ("shilin",
178    /// "shilin"): Somali does not inflect it here, and that is Python's table,
179    /// not a typo to fix.
180    forms: HashMap<&'static str, CurrencyForms>,
181}
182
183impl Default for LangSo {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189impl LangSo {
190    pub fn new() -> Self {
191        let mut forms = HashMap::with_capacity(3);
192        forms.insert(
193            "SOS",
194            CurrencyForms::new(&["shilin", "shilin"], &["sent", "sent"]),
195        );
196        forms.insert(
197            "USD",
198            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
199        );
200        forms.insert(
201            "EUR",
202            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
203        );
204        LangSo { forms }
205    }
206
207    /// Python's `Num2Word_SO._int_to_word`.
208    ///
209    /// Branch order is Python's, which matters: `== 0` is tested before `< 0`,
210    /// and the digit fallback is only reachable for *positive* values >= 10^9
211    /// because the `< 0` branch has already recursed on `abs(number)`.
212    fn int_to_word(&self, number: &BigInt) -> String {
213        // `if number == 0: return self.ones[0] if self.ones[0] else "zero"`
214        // — `ones[0]` is "" (falsy), so this is always "zero". See bug 1.
215        if number.is_zero() {
216            return ZERO_WORD.to_string();
217        }
218
219        // `if number < 0: return self.negword + self._int_to_word(abs(number))`
220        // Dead on every in-scope path (bug 5); preserved for fidelity.
221        if number.is_negative() {
222            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
223        }
224
225        // `else: return str(number)` — the digit fallback (bug 2). Must stay on
226        // BigInt: `number` is unbounded here.
227        if *number >= BigInt::from(DIGIT_FALLBACK_FLOOR) {
228            return number.to_string();
229        }
230
231        // Past this point Python's remaining branches all bound `number` to
232        // 1..=999_999_999, which is provably within u32 — so narrowing here is
233        // safe and the rest of the recursion can use fixed-width arithmetic.
234        // `to_u32` cannot fail given the checks above; fall back to the digit
235        // string rather than panic if that reasoning is ever invalidated.
236        match number.to_u32() {
237            Some(n) => self.small_to_word(n),
238            None => number.to_string(),
239        }
240    }
241
242    /// The `1 <= number < 10^9` tail of `_int_to_word`.
243    ///
244    /// Every recursive call Python makes here (`remainder`, `thousands_val`,
245    /// `millions_val`) is itself in `1..=999_999_999`, so recursing on `u32`
246    /// rather than back through [`LangSo::int_to_word`] is equivalent: neither
247    /// the zero guard, the negative branch, nor the digit fallback can trigger.
248    /// Each `remainder` recursion is guarded by Python's `if remainder:`.
249    fn small_to_word(&self, number: u32) -> String {
250        if number < 10 {
251            // `return self.ones[number]`
252            return ONES[number as usize].to_string();
253        }
254
255        if number < 100 {
256            let tens_val = (number / 10) as usize;
257            let ones_val = (number % 10) as usize;
258            if ones_val == 0 {
259                return TENS[tens_val].to_string();
260            }
261            return format!("{} {}", TENS[tens_val], ONES[ones_val]);
262        }
263
264        if number < 1_000 {
265            // Note: hundreds uses `self.ones[hundreds_val]` *directly*, not a
266            // recursive call — hence "kow boqol" for 100, never a bare "boqol".
267            let hundreds_val = (number / 100) as usize;
268            let remainder = number % 100;
269            let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
270            if remainder != 0 {
271                result.push(' ');
272                result.push_str(&self.small_to_word(remainder));
273            }
274            return result;
275        }
276
277        if number < 1_000_000 {
278            let thousands_val = number / 1_000;
279            let remainder = number % 1_000;
280            let mut result = format!("{} {}", self.small_to_word(thousands_val), THOUSAND);
281            if remainder != 0 {
282                result.push(' ');
283                result.push_str(&self.small_to_word(remainder));
284            }
285            return result;
286        }
287
288        // `elif number < 1000000000:` — guaranteed by the caller.
289        let millions_val = number / 1_000_000;
290        let remainder = number % 1_000_000;
291        let mut result = format!("{} {}", self.small_to_word(millions_val), MILLION);
292        if remainder != 0 {
293            result.push(' ');
294            result.push_str(&self.small_to_word(remainder));
295        }
296        result
297    }
298
299    /// The float/Decimal arm of `Num2Word_SO.to_cardinal`, driven by the
300    /// *string* `str(number)` exactly as Python is.
301    ///
302    /// ```python
303    /// n = str(number).strip()
304    /// if n.startswith("-"):
305    ///     n = n[1:]
306    ///     ret = self.negword          # "minus "
307    /// else:
308    ///     ret = ""
309    /// if "." in n:
310    ///     left, right = n.split(".", 1)
311    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
312    ///     for digit in right:
313    ///         ret += self._int_to_word(int(digit)) + " "
314    ///     return ret.strip()
315    /// else:
316    ///     return (ret + self._int_to_word(int(n))).strip()
317    /// ```
318    ///
319    /// This is deliberately **not** the base `float2tuple` path: SO reads the
320    /// literal repr digits, so a high-precision float keeps its exact shortest
321    /// round-trip fraction (`0.12345678901234` speaks all 14 digits) rather than
322    /// the `<0.01`-rescued / floored `post` the base path would compute. The
323    /// integer [`Lang::to_cardinal`] models the same method on the *value*, which
324    /// is only equivalent while there is no ".".
325    ///
326    /// Three faithful oddities:
327    ///
328    /// * The sign is stripped off the *string*, then `ret` (the negword) prefixes
329    ///   both branches — so a negative with zero integer part still prints
330    ///   `"minus zero ..."` (`int_to_word(0)` is "zero", bug 1), and the "."
331    ///   branch keeps its negword too.
332    /// * `int(digit)` runs per **character**, so a malformed fraction character
333    ///   raises `ValueError` quoting that one char, where a malformed whole `n`
334    ///   (e.g. the exponent-form repr `"1e+21"`) quotes the entire literal.
335    /// * Each fraction digit goes through [`LangSo::int_to_word`], not the raw
336    ///   `ones` table, so digit 0 speaks "zero" (not the empty `ones[0]`).
337    fn cardinal_from_str(&self, n: &str) -> Result<String> {
338        // Python's `str(number).strip()`; `str()` never emits surrounding space,
339        // so this only ever no-ops. Reproduced rather than assumed away.
340        let n = n.trim();
341
342        // `if n.startswith("-"): n = n[1:]; ret = self.negword; else: ret = ""`
343        let (n, ret): (&str, &str) = match n.strip_prefix('-') {
344            Some(rest) => (rest, NEGWORD),
345            None => (n, ""),
346        };
347
348        if let Some((left, right)) = n.split_once('.') {
349            // `ret += self._int_to_word(int(left)) + " " + self.pointword + " "`
350            let mut out = String::from(ret);
351            out.push_str(&self.int_to_word(&python_int(left)?));
352            out.push(' ');
353            out.push_str(self.pointword());
354            out.push(' ');
355            // `for digit in right: ret += self._int_to_word(int(digit)) + " "`
356            for ch in right.chars() {
357                let d = ch.to_digit(10).ok_or_else(|| {
358                    N2WError::Value(format!(
359                        "invalid literal for int() with base 10: '{}'",
360                        ch
361                    ))
362                })?;
363                out.push_str(&self.int_to_word(&BigInt::from(d)));
364                out.push(' ');
365            }
366            // `return ret.strip()`
367            Ok(out.trim().to_string())
368        } else {
369            // `return (ret + self._int_to_word(int(n))).strip()`
370            Ok(format!("{}{}", ret, self.int_to_word(&python_int(n)?))
371                .trim()
372                .to_string())
373        }
374    }
375}
376
377impl Lang for LangSo {
378
379    fn cardinal_float_entry(
380        &self,
381        value: &crate::floatpath::FloatValue,
382        precision_override: Option<u32>,
383    ) -> crate::base::Result<String> {
384        // Python's to_cardinal routes every float/Decimal through this
385        // language's own decimal grammar — 5.0 keeps its ".0" tail
386        // ("comma nulla"), unlike Base's whole-value integer route.
387        self.to_cardinal_float(value, precision_override)
388    }
389
390    /// `to_ordinal(float/Decimal)`. SO's `to_ordinal` is
391    /// `self.to_cardinal(number) + "-aad"` for *every* input, so the float
392    /// entry is the float cardinal plus the suffix — "kow point shan-aad".
393    /// An exponent-form Decimal repr ("1E+2") still dies in `int()` with
394    /// ValueError inside the cardinal, before the suffix is ever appended.
395    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
396        Ok(format!("{}-aad", self.cardinal_float_entry(value, None)?))
397    }
398
399    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — the repr the
400    /// binding computed, dot appended, sign and exponent form included
401    /// ("-0.0.", "1e+16.").
402    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
403        Ok(format!("{}.", repr_str))
404    }
405
406    /// `converter.str_to_number` — Base's `Decimal(value)`, with the Inf
407    /// interception: Python parses "Infinity" fine and the ValueError fires
408    /// later, inside SO's `int("Infinity")` (`to_cardinal` reads
409    /// `str(number)`, strips the sign, finds no "." and calls `int()`).
410    /// The binding otherwise hard-codes `ParsedNumber::Inf` to the base
411    /// integer path's OverflowError before any SO code runs, so the
412    /// ValueError must be raised here. The sign is sliced off before
413    /// `int()` sees it, so both signs quote the same literal.
414    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
415        match python_decimal_parse(s)? {
416            ParsedNumber::Inf { .. } => Err(N2WError::Value(
417                "invalid literal for int() with base 10: 'Infinity'".into(),
418            )),
419            other => Ok(other),
420        }
421    }
422
423    /// This language's own `to_currency(currency=...)` default,
424    /// read from the live Python signature. Only 44 of 156 use EUR.
425    fn default_currency(&self) -> &str {
426        "SOS"
427    }
428
429    /// This language's own `to_currency(separator=...)` default,
430    /// read from the live Python signature. Base's is ",", but only
431    /// 36 of 149 languages actually use it — most default to " " or a
432    /// conjunction, so inheriting Base's comma silently corrupts them.
433    fn default_separator(&self) -> &str {
434        " "
435    }
436
437    fn negword(&self) -> &str {
438        NEGWORD
439    }
440
441    fn pointword(&self) -> &str {
442        "point"
443    }
444
445    /// Python's `Num2Word_SO.to_cardinal`.
446    ///
447    /// Python works on the *string*: `n = str(number).strip()`, then strips a
448    /// leading "-" off `n` and re-parses with `int(n)`. For integer input that
449    /// is exactly `value.abs()` (a decimal repr has no surrounding whitespace,
450    /// and BigInt has no negative zero), so the string round-trip is elided.
451    /// The `"." in n` float branch is unreachable for integers and omitted.
452    ///
453    /// The trailing `.strip()` is kept as `trim()`: `negword` ends in a space,
454    /// so it would matter if `_int_to_word` ever returned an empty string. It
455    /// cannot (0 yields "zero", bug 1), making the trim a no-op in practice —
456    /// but it is Python's, so it stays.
457    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
458        let (magnitude, ret) = if value.is_negative() {
459            (value.abs(), NEGWORD)
460        } else {
461            (value.clone(), "")
462        };
463
464        // `_int_to_word` receives a non-negative value here — hence bug 5.
465        Ok(format!("{}{}", ret, self.int_to_word(&magnitude))
466            .trim()
467            .to_string())
468    }
469
470    /// `return cardinal + "-aad"`. Applied to the whole string, so the suffix
471    /// lands on the last word only: `to_ordinal(-1)` == "minus kow-aad" and
472    /// `to_ordinal(10**9)` == "1000000000-aad" (bug 2).
473    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
474        Ok(format!("{}-aad", self.to_cardinal(value)?))
475    }
476
477    /// `return str(number) + "."` — sign and all. See bug 6.
478    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
479        Ok(format!("{}.", value))
480    }
481
482    /// `def to_year(self, val, longval=True): return self.to_cardinal(val)`.
483    /// `longval` is accepted and then ignored, so years get no century
484    /// treatment: 1900 is plain "kow kun sagaal boqol", not "nineteen hundred".
485    fn to_year(&self, value: &BigInt) -> Result<String> {
486        self.to_cardinal(value)
487    }
488
489    /// Port of the float/Decimal path of `Num2Word_SO.to_cardinal`.
490    ///
491    /// SO overrides `to_cardinal` (not `to_cardinal_float`) and handles
492    /// non-integers inline off `str(number)`. The dispatcher never routes SO
493    /// through the base `to_cardinal_float`, so the Rust core reconstructs
494    /// `str(number)` — [`python_str`] mirrors CPython's `repr(float)` and
495    /// `str(Decimal)` exactly — and replays SO's string algorithm in
496    /// [`LangSo::cardinal_from_str`].
497    ///
498    /// `precision_override` (the `precision=` kwarg) is **ignored**: SO's
499    /// `to_cardinal` takes no precision argument and never reads `self.precision`
500    /// (confirmed against the live interpreter — `precision=` leaves the output
501    /// unchanged), so honouring it would diverge from Python.
502    fn to_cardinal_float(
503        &self,
504        value: &FloatValue,
505        _precision_override: Option<u32>,
506    ) -> Result<String> {
507        self.cardinal_from_str(&python_str(value))
508    }
509
510    // ---- currency ----------------------------------------------------
511
512    fn lang_name(&self) -> &str {
513        LANG_NAME
514    }
515
516    /// `CURRENCY_FORMS[code]` — a strict lookup, used only by
517    /// `default_to_cheque`, which turns `None` into Python's
518    /// `NotImplementedError`.
519    ///
520    /// `to_currency` deliberately does **not** route through this hook: Python
521    /// reaches the same dict via `.get(currency, <SOS forms>)`, which never
522    /// raises. Hence `to_cheque("GBP")` raises while `to_currency("GBP")`
523    /// happily prints shillings. That asymmetry is real and the corpus pins
524    /// both halves of it.
525    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
526        self.forms.get(code)
527    }
528
529    // `currency_precision` is intentionally NOT overridden: `Num2Word_SO`
530    // inherits `Num2Word_Base.CURRENCY_PRECISION`, which is `{}` (confirmed
531    // against the live interpreter), so every code takes the `.get(code, 100)`
532    // default of 100 — exactly the trait default. This is why BHD/KWD
533    // (nominally 3-decimal) and JPY (nominally 0-decimal) behave like EUR.
534    //
535    // `currency_adjective` is NOT overridden: `CURRENCY_ADJECTIVES` is `{}`.
536    //
537    // `pluralize` / `cents_verbose` / `cents_terse` are NOT overridden and are
538    // unreachable: SO's `to_currency` does its own form selection, and
539    // `default_to_cheque` takes `forms.unit.last()` without pluralizing. The
540    // trait default for `pluralize` raises NotImplemented, mirroring Python's
541    // abstract `raise NotImplementedError` — correct precisely because nothing
542    // calls it.
543    //
544    // `money_verbose` is NOT overridden: base's `_money_verbose` is
545    // `self.to_cardinal(number)`, which is the trait default, and it dispatches
546    // to SO's overridden `to_cardinal`. `to_cheque` relies on that.
547    //
548    // `to_cheque` is NOT overridden: `Num2Word_SO` does not define it, so
549    // Python runs `Num2Word_Base.to_cheque` — `currency::default_to_cheque`.
550
551    /// Port of `Num2Word_SO.to_currency` — a full override sharing no code with
552    /// `Num2Word_Base.to_currency`.
553    ///
554    /// ```python
555    /// def to_currency(self, val, currency="SOS", cents=True, separator=" ", adjective=False):
556    ///     is_negative = False
557    ///     if val < 0:
558    ///         is_negative = True
559    ///         val = abs(val)
560    ///     parts = str(val).split(".")
561    ///     left = int(parts[0]) if parts[0] else 0
562    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
563    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
564    ///     left_str = self._int_to_word(left)
565    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
566    ///     if cents and right:
567    ///         cents_str = self._int_to_word(right)
568    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
569    ///     if is_negative:
570    ///         result = self.negword + result
571    ///     return result.strip()
572    /// ```
573    ///
574    /// # Quirks reproduced (each pinned by a corpus row)
575    ///
576    /// 7. **An unknown currency never raises.** `.get(currency, <SOS forms>)`
577    ///    silently substitutes the Somali shilling, so `to_currency(1, "GBP")`
578    ///    is `"kow shilin"` — a pound rendered as a *shilling*, with no error.
579    ///    The corpus pins this for GBP/JPY/KWD/BHD/INR/CNY/CHF, all of which
580    ///    come out identical to SOS.
581    /// 8. **Cents are parsed lexically and truncated, never rounded.**
582    ///    `parts[1][:2]` takes the first two fraction *characters*, so `0.5` ->
583    ///    `"5"` -> `ljust(2, "0")` -> `"50"` -> 50 cents (right), but `12.345`
584    ///    -> `"34"` -> 34 cents, **truncating** the 5 that base's
585    ///    `ROUND_HALF_UP` would carry to 35. `parse_currency_parts` is never
586    ///    reached.
587    /// 9. **`CURRENCY_PRECISION` is ignored outright.** The divisor is
588    ///    hard-wired to "first two fraction digits", so JPY (0-decimal) still
589    ///    gets cents — `to_currency(12.34, "JPY")` is
590    ///    `"toban laba shilin soddon afar sent"` — and KWD/BHD get 2 subunits,
591    ///    not 3. Corpus-confirmed for all four codes.
592    /// 10. **`adjective` is accepted and never read.** `CURRENCY_ADJECTIVES` is
593    ///    empty anyway, so even base's behaviour would be a no-op.
594    /// 11. **Int and float converge here, but only by accident.** Python
595    ///    branches on `str(val)`, not `isinstance(val, int)`: `str(1)` is `"1"`
596    ///    (one part -> `right = 0`) while `str(1.0)` is `"1.0"`
597    ///    (`parts[1] == "0"`, truthy -> `right = int("00") == 0`). Both land on
598    ///    `right == 0`, which is falsy, so both skip cents and print
599    ///    `"kow euro"`. The two variants are kept distinct below rather than
600    ///    collapsed, because the *reason* they agree is arithmetic on `right`,
601    ///    not a shared branch.
602    /// 12. **`cents=False` drops the segment entirely** rather than falling back
603    ///    to the terse digit form base would use — `_cents_terse` is
604    ///    unreachable.
605    ///
606    /// # Errors
607    ///
608    /// `N2WError::Value` where Python's `int()` raises `ValueError` on a
609    /// non-numeric field — reachable only for a float whose repr is in
610    /// scientific notation (see the port report's concerns).
611    fn to_currency(
612        &self,
613        val: &CurrencyValue,
614        currency: &str,
615        cents: bool,
616        separator: Option<&str>,
617        adjective: bool,
618    ) -> Result<String> {
619        // Trait now hands us None when the caller omitted separator=;
620        // resolve it to this language's own default before the ported body.
621        let separator = separator.unwrap_or(self.default_separator());
622        let _ = adjective; // Python names the parameter and never reads it. Quirk 10.
623
624        // Restore SO's own separator default. See [`SEPARATOR_UNSET`].
625        let separator = if separator == SEPARATOR_UNSET {
626            SEPARATOR_DEFAULT
627        } else {
628            separator
629        };
630
631        // Python: `if val < 0: is_negative = True; val = abs(val)` — the abs()
632        // happens *before* str(), so the sign never reaches the split.
633        let is_negative = val.is_negative();
634        let s = match val {
635            // `str(int)` — never contains ".", so `parts` has length 1 and
636            // `right` stays 0. Quirk 11.
637            CurrencyValue::Int(i) => if is_negative { i.abs() } else { i.clone() }.to_string(),
638            // `str(float)`. The Python shim already stringified the float with
639            // its own repr and the core parsed that, so `BigDecimal::to_string`
640            // reproduces `str(val)` exactly for every ordinary decimal repr —
641            // crucially preserving the trailing ".0" of `1.0`, which is what
642            // makes quirk 11 work out.
643            CurrencyValue::Decimal { value: d, .. } => if is_negative { d.abs() } else { d.clone() }.to_string(),
644        };
645
646        // Python: `parts = str(val).split(".")`, then indexes `parts[0]` and
647        // `parts[1]`. Splitting lazily gives the same two fields: `p1` is the
648        // text between the first and second dot, exactly as Python would see it.
649        let mut parts = s.split('.');
650        let p0 = parts.next().unwrap_or("");
651        let p1 = parts.next();
652
653        // `left = int(parts[0]) if parts[0] else 0`
654        let left = if p0.is_empty() {
655            BigInt::zero()
656        } else {
657            BigInt::from_str(p0).map_err(|_| {
658                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", p0))
659            })?
660        };
661
662        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
663        // Sliced by chars(), per the porting contract. Quirk 8.
664        let right = match p1 {
665            Some(frac) if !frac.is_empty() => {
666                let mut f: String = frac.chars().take(2).collect();
667                while f.chars().count() < 2 {
668                    f.push('0'); // ljust(2, "0")
669                }
670                BigInt::from_str(&f).map_err(|_| {
671                    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", f))
672                })?
673            }
674            _ => BigInt::zero(),
675        };
676
677        // `.get(currency, list(CURRENCY_FORMS.values())[0])` — SOS on a miss,
678        // never an error. Quirk 7.
679        let forms = self.forms.get(currency).unwrap_or_else(|| {
680            self.forms
681                .get(FALLBACK_CURRENCY)
682                .expect("new() always inserts SOS")
683        });
684        let (cr1, cr2) = (&forms.unit, &forms.subunit);
685
686        let one = BigInt::from(1);
687        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
688        let mut result = format!(
689            "{} {}",
690            self.int_to_word(&left),
691            if left != one { &cr1[1] } else { &cr1[0] }
692        );
693
694        // `if cents and right:` — `right` is falsy at 0, so a float with zero
695        // cents (1.0) prints no cents segment. Quirks 11 and 12.
696        if cents && !right.is_zero() {
697            // Python: `result += separator + cents_str + " " + ...`. No space is
698            // inserted *before* the separator; SO's " " default supplies it.
699            result.push_str(separator);
700            result.push_str(&format!(
701                "{} {}",
702                self.int_to_word(&right),
703                if right != one { &cr2[1] } else { &cr2[0] }
704            ));
705        }
706
707        // `if is_negative: result = self.negword + result` — "minus " (with its
708        // trailing space) is prepended, then the whole string is stripped.
709        if is_negative {
710            result = format!("{}{}", NEGWORD, result);
711        }
712        Ok(result.trim().to_string())
713    }
714}
715
716/// `str(number)` for whatever the Python dispatcher handed SO's `to_cardinal`.
717///
718/// The [`FloatValue`] split is exactly Python's `isinstance(value, Decimal)`:
719/// the two arms stringify by different rules (`repr(float)` vs `str(Decimal)`)
720/// and must not be collapsed — issue #603 exists precisely because a `float()`
721/// cast rounds a large Decimal away.
722fn python_str(v: &FloatValue) -> String {
723    match v {
724        FloatValue::Float { value, .. } => python_float_repr(*value),
725        FloatValue::Decimal { value, .. } => python_decimal_str(value),
726    }
727}
728
729/// Python's `int(s)`, for the strings `str()` can produce.
730///
731/// `BigInt::from_str` and `int()` agree on every reachable input: plain ASCII
732/// digit runs with an optional sign. Where they diverge (`int()` also accepts
733/// surrounding whitespace, underscores, non-ASCII digits) is unreachable from
734/// `str(float)` / `str(Decimal)`. The message is Python's, quoting the offending
735/// literal verbatim — so an exponent-form repr like `"1e+21"` (no ".") reaches
736/// the non-fractional branch and raises `ValueError: invalid literal for int()
737/// with base 10: '1e+21'`, exactly as the live interpreter does.
738fn python_int(s: &str) -> Result<BigInt> {
739    BigInt::from_str(s).map_err(|_| {
740        N2WError::Value(format!(
741            "invalid literal for int() with base 10: '{}'",
742            s
743        ))
744    })
745}
746
747/// CPython's `repr(float)` — shortest round-trip, with its exact exponent and
748/// `.0` rules. Rust's `{}` is shortest round-trip too but never adds a `.0` and
749/// never uses exponent form, so `1.0` and `1e16` would both come out wrong; this
750/// transcribes CPython's `float_repr` / `format_float_short` instead.
751///
752/// The carried `precision` is deliberately *not* used to shortcut this: it is
753/// `abs(Decimal(str(value)).as_tuple().exponent)`, which for an exponent-form
754/// repr is the exponent, not a fraction-digit count (`1e16` arrives with
755/// `precision == 16`).
756fn python_float_repr(v: f64) -> String {
757    // repr(nan) / repr(inf) / repr(-inf); fed straight to int(), which rejects
758    // them like any other bad literal.
759    if v.is_nan() {
760        return "nan".to_string();
761    }
762    if v.is_infinite() {
763        return (if v.is_sign_negative() { "-inf" } else { "inf" }).to_string();
764    }
765    // The sign bit, not `v < 0.0`: repr(-0.0) is "-0.0".
766    let sign = if v.is_sign_negative() { "-" } else { "" };
767    let a = v.abs();
768
769    // `{:e}` is shortest-round-trip in `<d>[.<ddd>]e<exp>` form, so the digits
770    // and the decimal-point position fall straight out. `decpt` is CPython's:
771    // the value is `0.<digits> * 10**decpt`.
772    let s = format!("{:e}", a);
773    let (mant, exp) = s.split_once('e').expect("LowerExp always emits an 'e'");
774    let exp: i32 = exp.parse().expect("LowerExp emits an integer exponent");
775    let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
776    let mut decpt = exp + 1;
777
778    // Tie repair: `{:e}` and CPython can pick different-looking shortest forms
779    // for a half-way value. Re-round to the same number of fractional digits to
780    // realign. Only reachable when the shortest form has fractional digits.
781    let frac_digits = digits.chars().count() as i32 - decpt;
782    if frac_digits > 0 && a != 0.0 {
783        let t = format!("{:.*}", frac_digits as usize, a);
784        let (ip, fp) = t.split_once('.').expect("frac_digits > 0 forces a point");
785        let all = format!("{}{}", ip, fp);
786        let trimmed = all.trim_start_matches('0');
787        if !trimmed.is_empty() {
788            let lead = all.chars().count() - trimmed.chars().count();
789            digits = trimmed.to_string();
790            decpt = ip.chars().count() as i32 - lead as i32;
791        }
792    }
793
794    let n = digits.chars().count() as i32;
795
796    if decpt <= -4 || decpt > 16 {
797        // CPython: mantissa, then "e", then "%+.02d" of decpt-1.
798        let e = decpt - 1;
799        let mut out = String::from(sign);
800        let mut it = digits.chars();
801        out.push(it.next().expect("a finite double has at least one digit"));
802        if n > 1 {
803            out.push('.');
804            out.push_str(it.as_str());
805        }
806        out.push('e');
807        out.push(if e < 0 { '-' } else { '+' });
808        out.push_str(&format!("{:02}", (e as i64).abs()));
809        out
810    } else if decpt <= 0 {
811        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
812    } else if decpt >= n {
813        // Py_DTSF_ADD_DOT_0: an integral value still reprs with a ".0".
814        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - n) as usize))
815    } else {
816        let k = decpt as usize;
817        format!(
818            "{}{}.{}",
819            sign,
820            digits.chars().take(k).collect::<String>(),
821            digits.chars().skip(k).collect::<String>()
822        )
823    }
824}
825
826/// CPython's `str(Decimal)` — the spec's to-scientific-string, transcribed from
827/// `_pydecimal.Decimal.__str__`:
828///
829/// ```python
830/// leftdigits = self._exp + len(self._int)
831/// if self._exp <= 0 and leftdigits > -6:
832///     dotplace = leftdigits          # no exponent required
833/// else:
834///     dotplace = 1                   # scientific: 1 digit left of the point
835/// if dotplace <= 0:
836///     intpart, fracpart = '0', '.' + '0'*(-dotplace) + self._int
837/// elif dotplace >= len(self._int):
838///     intpart, fracpart = self._int + '0'*(dotplace-len(self._int)), ''
839/// else:
840///     intpart, fracpart = self._int[:dotplace], '.' + self._int[dotplace:]
841/// exp = '' if leftdigits == dotplace else 'E' + "%+d" % (leftdigits-dotplace)
842/// return sign + intpart + fracpart + exp
843/// ```
844///
845/// `BigDecimal` is the same `(unscaled, scale)` pair as Python's `(_int, _exp)`
846/// (`_exp == -scale`), and `from_str` preserves the scale as written, so
847/// `Decimal("1.10")`'s trailing zero survives. This reads
848/// `as_bigint_and_exponent()` rather than `BigDecimal`'s own `Display`, which is
849/// **not** `str(Decimal)` (it renders `Decimal("0.00")` as `"0"`, dropping the
850/// digits SO would speak). The capital `E` and unpadded exponent are Python's.
851///
852/// # The negative-zero hole
853///
854/// Python's `Decimal` carries a sign flag independent of its digits, so
855/// `Decimal("-0.0")` is negative. `BigInt` has no negative zero, so
856/// `BigDecimal::from_str("-0.0")` discards the sign before this function sees it,
857/// and the leading `minus` is lost for `Decimal("-0")`/`"-0.0"`/`"-0.00"` only.
858/// The discriminator is the original string, which the `FloatValue::Decimal`
859/// boundary does not carry. The *float* `-0.0` is fine (f64 keeps its sign bit).
860/// Flagged in the port report.
861fn python_decimal_str(d: &BigDecimal) -> String {
862    let (unscaled, scale) = d.as_bigint_and_exponent();
863    let sign = if unscaled.is_negative() { "-" } else { "" };
864    // Python's `_int`: the unsigned coefficient. BigInt renders ASCII digits.
865    let int_digits = unscaled.abs().to_string();
866    let exp: i64 = -scale;
867    let ndig = int_digits.chars().count() as i64;
868    let leftdigits = exp + ndig;
869    let dotplace = if exp <= 0 && leftdigits > -6 {
870        leftdigits
871    } else {
872        1
873    };
874
875    let (intpart, fracpart) = if dotplace <= 0 {
876        (
877            "0".to_string(),
878            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
879        )
880    } else if dotplace >= ndig {
881        (
882            format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
883            String::new(),
884        )
885    } else {
886        let k = dotplace as usize;
887        (
888            int_digits.chars().take(k).collect::<String>(),
889            format!(".{}", int_digits.chars().skip(k).collect::<String>()),
890        )
891    };
892
893    // `"%+d"` — always signed, never zero-padded. Capital E: context.capitals
894    // defaults to 1.
895    let exp_part = if leftdigits == dotplace {
896        String::new()
897    } else {
898        format!("E{:+}", leftdigits - dotplace)
899    };
900
901    format!("{}{}{}{}", sign, intpart, fracpart, exp_part)
902}