Skip to main content

num2words2_core/
lang_sd.rs

1//! Port of `lang_SD.py` (Sindhi).
2//!
3//! Shape: **self-contained**. `Num2Word_SD` subclasses `Num2Word_Base` but its
4//! `setup()` defines none of `high_numwords`/`mid_numwords`/`low_numwords`, so
5//! the `any(hasattr(...))` guard in `Num2Word_Base.__init__` never fires:
6//! `self.cards` is never built and `self.MAXVAL` is never set. `to_cardinal` is
7//! overridden outright and drives a hand-rolled `_int_to_word` recursion.
8//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here, and
9//! there is **no overflow check** — arbitrarily large input is accepted (see
10//! bug 3 below for what it returns).
11//!
12//! All in-scope modes are overridden by the Python class itself. Python's one
13//! `to_cardinal` serves both integer and float input (its `"." in n` branch);
14//! the Rust trait splits those, so the integer half lands in `to_cardinal` and
15//! the fractional half in `to_cardinal_float` (see that method for the float/
16//! Decimal port and its two out-of-corpus divergences):
17//!   * `to_cardinal(n)`    -> the `_int_to_word` recursion, with sign handling
18//!   * `to_ordinal(n)`     -> `to_cardinal(n) + "-و"`
19//!   * `to_ordinal_num(n)` -> `str(n) + "."`  (note: *not* the base default,
20//!     which returns `str(value)` with no dot)
21//!   * `to_year(val, longval=True)` -> `to_cardinal(val)`, ignoring `longval`
22//!     entirely. Identical to the base default, but overridden explicitly here
23//!     to mirror the Python source.
24//!
25//! `Num2Word_SD` additionally overrides `to_currency` outright, but **not**
26//! `to_cheque`, `pluralize`, `_money_verbose`, `_cents_verbose` or
27//! `_cents_terse`; see "Currency" below.
28//!
29//! Nothing in this module reads or writes instance state across calls. The
30//! struct below holds only the `CURRENCY_FORMS` table, built once in
31//! [`LangSd::new`] (which the generated registry caches in a `OnceLock`) and
32//! thereafter read-only, so the Python dispatcher's `_plain_int` handshake
33//! caveat still does not apply.
34//!
35//! # Faithfully reproduced Python bugs
36//!
37//! This is a port, not a rewrite. The following are all wrong-looking but are
38//! exactly what Python emits, verified against the interpreter and against the
39//! frozen corpus:
40//!
41//! 1. **`to_cardinal(0)` == `"zero"`** — the English word, in an otherwise
42//!    all-Sindhi table. `_int_to_word` opens with
43//!    `return self.ones[0] if self.ones[0] else "zero"`, and `ones[0]` is the
44//!    empty string (a falsy placeholder so that `ones[n]` indexes by digit).
45//!    The conditional therefore always takes the `"zero"` branch — the guard is
46//!    dead. Modelled by [`ZERO`].
47//! 2. **No teens.** Sindhi has distinct words for 11-19, but the `< 100` branch
48//!    unconditionally composes `tens[n // 10] + " " + ones[n % 10]`. So 11 is
49//!    "ڏهه هڪ" — literally "ten one" — 13 is "ڏهه ٽي", and 19 is "ڏهه نو". This
50//!    propagates upward: 12345 == "ڏهه ٻه هزار ٽي سو چاليهه پنج" ("ten two
51//!    thousand ..."). Preserved verbatim.
52//! 3. **Everything >= 10^9 renders as bare ASCII digits.** The `_int_to_word`
53//!    branch chain ends in `else: return str(number)  # Fallback for very large
54//!    numbers`, so `to_cardinal(10**9)` == `"1000000000"` and
55//!    `to_cardinal(10**21)` == `"1000000000000000000000"`. No exception, no
56//!    words. Ordinal inherits it: `to_ordinal(10**9)` == `"1000000000-و"`.
57//! 4. **`million` is "لک" (lakh) but is applied at 10^6.** A lakh is 10^5 in the
58//!    South Asian system, so the scale name is off by a factor of ten, and the
59//!    genuine Sindhi grouping (lakh/crore) is not used at all — the code groups
60//!    in Western thousands/millions. Hence 10^6 == "هڪ لک" ("one lakh") and
61//!    123456789 == "هڪ سو ويهه ٽي لک ..." ("one hundred twenty three lakh ...").
62//! 5. **`negword` is the English "minus "**, not a Sindhi word, and it is
63//!    *prepended* to right-to-left script: `to_cardinal(-1)` == "minus هڪ".
64//! 6. Orthographic inconsistency in the tables, kept byte-exact: `ones[1]`
65//!    ("هڪ") spells its kaf with U+06AA ARABIC LETTER SWASH KAF, while `million`
66//!    ("لک") uses U+06A9 ARABIC LETTER KEHEH. These are distinct codepoints that
67//!    render near-identically, so the tables below use explicit `\u{...}`
68//!    escapes rather than pasted glyphs — a copy-paste round trip through an
69//!    editor that normalises Arabic presentation forms would silently corrupt
70//!    them, and the corpus compares bytes.
71//!
72//! # Currency
73//!
74//! `to_currency` is overridden by the Python class and shares essentially
75//! nothing with `Num2Word_Base.to_currency`: it never calls
76//! `parse_currency_parts`, `pluralize`, `_cents_verbose` or `_cents_terse`, and
77//! never consults `CURRENCY_PRECISION` or `CURRENCY_ADJECTIVES`. It splits
78//! `str(val)` on `"."` and works on the two halves textually. Consequences,
79//! each verified against the live interpreter and the frozen corpus:
80//!
81//! 1. **An unknown currency code does not raise.** `to_currency` looks the code
82//!    up with `CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])`,
83//!    so anything outside {PKR, USD, EUR} silently renders in Pakistani rupees —
84//!    the first entry by insertion order. The corpus tests seven such codes
85//!    (GBP, JPY, KWD, BHD, INR, CNY, CHF) and every row comes back in rupees and
86//!    paisas. `to_cheque`, which SD does *not* override, keeps the base's plain
87//!    `CURRENCY_FORMS[currency]` subscript and so *does* raise for those same
88//!    seven codes. The two entry points disagree, deliberately.
89//! 2. **`CURRENCY_PRECISION` is ignored.** The subunit is always hundredths,
90//!    hardcoded by the `parts[1][:2]` slice. There is no 3-decimal (KWD/BHD,
91//!    divisor 1000) or 0-decimal (JPY, divisor 1) handling, and the base's
92//!    zero-decimal rounding branch is unreachable. Since SD inherits
93//!    `CURRENCY_PRECISION = {}` from `Num2Word_Base` those codes would resolve
94//!    to 100 regardless, so this is invisible in the corpus — `currency:JPY` and
95//!    `currency:KWD` render identically to `currency:PKR`, cents and all.
96//! 3. **No rounding, only truncation.** `12.999` bills 99 cents and `2.675`
97//!    bills 67 — the base path's `ROUND_HALF_UP` would give 100 and 68.
98//! 4. **`adjective` is accepted and ignored**, so `CURRENCY_ADJECTIVES` (empty
99//!    anyway) is never read.
100//! 5. **`cents=False` suppresses the cents segment entirely** rather than
101//!    switching to terse digits as the base class does: the guard is
102//!    `if cents and right:`, and a false `cents` drops the whole clause.
103//!    `to_currency(12.34, cents=False)` == `"ڏهه ٻه euros"`.
104//!
105//! `to_cheque` is inherited unmodified and needs no override here: the base
106//! implementation's hooks (`currency_forms`, `currency_precision` at its default
107//! 100, `money_verbose` delegating to `to_cardinal`) already resolve to SD's
108//! behaviour.
109//!
110//! # Known divergences
111//!
112//! Two, both outside the corpus, both stemming from information the
113//! `CurrencyValue` boundary cannot carry:
114//!
115//! 1. **An explicit `separator=","` renders as `" "`.** The dispatcher
116//!    substitutes `Num2Word_Base`'s `","` when the caller supplies no separator,
117//!    so `","` has to be read as "unset" and replaced with SD's own `" "`
118//!    default — which makes a deliberate `","` unrepresentable. Every other
119//!    separator is honoured verbatim. The corpus evidence for resolving it this
120//!    way, and the reason it cannot be fixed from inside this file, is on
121//!    [`SEPARATOR_UNSET`].
122//! 2. **Scientific-notation input raises `ValueError` in Python, not here.**
123//!    `str(float)` switches to exponent form at `1e16` and below `1e-4`, and
124//!    `int("1e+21")` then throws: `to_currency(1e21)` and `to_currency(1e-5)`
125//!    are both `ValueError`. The shim hands the core `str(value)`, but
126//!    `CurrencyValue::parse` turns it into a `BigDecimal`, which accepts
127//!    exponent notation and discards the fact that it was used —
128//!    `"1e-05"` and `"0.00001"` become indistinguishable. Reproducing the raise
129//!    would need the original string. This path renders the value instead (via
130//!    bug 3's digit fallback for the large case). No corpus row reaches it.
131//!
132//! # Error variants
133//!
134//! `to_cheque` raises `N2WError::NotImplemented` for a code absent from
135//! `CURRENCY_FORMS`, via the inherited `currency::default_to_cheque` and the
136//! [`Lang::lang_name`] override below — Python's
137//! `NotImplementedError: Currency code "GBP" not implemented for "Num2Word_SD"`,
138//! reached through the `KeyError` that `to_cheque`'s bare subscript throws.
139//!
140//! Otherwise none. The four integer modes and `to_currency` have no reachable
141//! crash site and no deliberate `raise`: every list index is derived from a digit
142//! of a value already bounded by the enclosing branch, so `ones[i]`/`tens[i]` can
143//! only be hit with `i` in `0..=9`. With no `MAXVAL` there is no `OverflowError`
144//! either (bug 3 swallows the large-input case). `pluralize` is left at the
145//! trait default that raises, matching `Num2Word_Base.pluralize` — SD reaches it
146//! from neither `to_currency` (which inlines its own two-form selection) nor
147//! `to_cheque` (which takes `cr1[-1]` directly).
148
149use crate::base::{Lang, N2WError, Result};
150use crate::currency::{CurrencyForms, CurrencyValue};
151use crate::floatpath::{float2tuple, FloatValue};
152use bigdecimal::BigDecimal;
153use num_bigint::BigInt;
154use num_traits::{One, Signed, ToPrimitive, Zero};
155use std::collections::HashMap;
156
157/// `_int_to_word`'s zero case. Python evaluates
158/// `self.ones[0] if self.ones[0] else "zero"`, and `ones[0]` is `""` (falsy),
159/// so this English literal is what a Sindhi zero actually returns. See bug 1.
160const ZERO: &str = "zero";
161
162/// `self.negword`. English, and prepended to RTL output. See bug 5.
163const NEGWORD: &str = "minus ";
164
165/// `self.ones`. Index 0 is the empty placeholder that makes bug 1 possible;
166/// it is never emitted, because every call site either guards on a nonzero
167/// remainder or is the `_int_to_word` zero branch that returns [`ZERO`].
168const ONES: [&str; 10] = [
169    "",                                     // (empty placeholder — see bug 1)
170    "\u{0647}\u{06AA}",                     // هڪ    (hik)    — note U+06AA, cf. bug 6
171    "\u{067B}\u{0647}",                     // ٻه    (ba)
172    "\u{067D}\u{064A}",                     // ٽي    (ṭe)
173    "\u{0686}\u{0627}\u{0631}",             // چار   (chār)
174    "\u{067E}\u{0646}\u{062C}",             // پنج   (panj)
175    "\u{0687}\u{0647}\u{0647}",             // ڇهه   (chhahe)
176    "\u{0633}\u{062A}",                     // ست    (sat)
177    "\u{0627}\u{067A}",                     // اٺ    (aṭh)
178    "\u{0646}\u{0648}",                     // نو    (nav)
179];
180
181/// `self.tens`. Index 0 is an unused empty placeholder: the `< 100` branch is
182/// only entered when `number >= 10`, so `tens[number // 10]` has index >= 1.
183const TENS: [&str; 10] = [
184    "",                                                     // (unreachable placeholder)
185    "\u{068F}\u{0647}\u{0647}",                             // ڏهه     (ḍahe)
186    "\u{0648}\u{064A}\u{0647}\u{0647}",                     // ويهه    (vīhe)
187    "\u{067D}\u{064A}\u{0647}\u{0647}",                     // ٽيهه    (ṭīhe)
188    "\u{0686}\u{0627}\u{0644}\u{064A}\u{0647}\u{0647}",     // چاليهه  (chālīhe)
189    "\u{067E}\u{0646}\u{062C}\u{0627}\u{0647}\u{0647}",     // پنجاهه  (panjāhe)
190    "\u{0633}\u{067A}",                                     // سٺ      (saṭh)
191    "\u{0633}\u{062A}\u{0631}",                             // ستر     (satar)
192    "\u{0627}\u{0633}\u{064A}",                             // اسي     (asī)
193    "\u{0646}\u{0648}\u{064A}",                             // نوي     (navī)
194];
195
196/// `self.hundred` — سو (so).
197const HUNDRED: &str = "\u{0633}\u{0648}";
198/// `self.thousand` — هزار (hazār).
199const THOUSAND: &str = "\u{0647}\u{0632}\u{0627}\u{0631}";
200/// `self.million` — لک (lakh). Applied at 10^6 despite meaning 10^5; see bug 4.
201/// Note U+06A9 KEHEH here vs U+06AA SWASH KAF in `ONES[1]` (bug 6).
202const MILLION: &str = "\u{0644}\u{06A9}";
203
204/// The suffix `to_ordinal` appends: `"-و"` (hyphen + U+0648 ARABIC LETTER WAW).
205const ORDINAL_SUFFIX: &str = "-\u{0648}";
206
207/// `self.pointword` — the word between the integer and fractional parts on the
208/// float path. Plain ASCII "point", not a Sindhi word (matching the tables'
209/// other English intrusions, cf. bugs 1 and 5).
210const POINTWORD: &str = "point";
211
212/// The 10^9 ceiling past which `_int_to_word` gives up and returns digits.
213const FALLBACK_THRESHOLD: u64 = 1_000_000_000;
214
215// ---- currency ----------------------------------------------------------
216//
217// `Num2Word_SD.CURRENCY_FORMS` is a class attribute the class declares itself,
218// so — unlike the EUR-family languages — it is *not* the dict `Num2Word_EN`
219// mutates at import time. Verified against the live interpreter: SD's table
220// holds exactly PKR/USD/EUR and nothing English ever added. `CURRENCY_ADJECTIVES`
221// and `CURRENCY_PRECISION` are both inherited unset (`{}`) from `Num2Word_Base`,
222// so precision is 100 for every code (the trait default) and the adjective hook
223// is never consulted.
224
225/// PKR unit forms — روپي (rupī, sg) / روپيا (rupiyā, pl). Escaped rather than
226/// pasted, for the same reason as the numeral tables: see bug 6.
227const RUPEE_SG: &str = "\u{0631}\u{0648}\u{067E}\u{064A}";
228const RUPEE_PL: &str = "\u{0631}\u{0648}\u{067E}\u{064A}\u{0627}";
229/// PKR subunit forms — پئسو (paiso, sg) / پئسا (paisā, pl).
230const PAISA_SG: &str = "\u{067E}\u{0626}\u{0633}\u{0648}";
231const PAISA_PL: &str = "\u{067E}\u{0626}\u{0633}\u{0627}";
232
233/// `Num2Word_SD.to_currency`'s own default: `separator=" "`, a bare space — not
234/// the `","` that `Num2Word_Base.to_currency` defaults to.
235const SD_SEPARATOR: &str = " ";
236
237/// The separator the binding hands us when the Python caller supplied none.
238///
239/// `Num2Word_SD.to_currency` declares `separator=" "`, but the `Lang` trait takes
240/// `separator` as a plain required argument, so "caller omitted it" has to be
241/// encoded in the value itself. Both callers substitute **`Num2Word_Base`'s**
242/// default rather than SD's, because neither can see a per-language signature:
243/// `num2words2/__init__.py` forwards `kwargs.get("separator", ",")`, and
244/// `bench/diff_test.py` hardcodes `","`. By the time the value crosses the
245/// boundary, "omitted" and "explicitly `,`" are indistinguishable.
246///
247/// The corpus proves `","` must be read as "omitted" here. Rows are generated by
248/// `num2words(v, lang=l, to="currency", currency=c)` with no `separator=`, yet
249/// `de` — whose Python default is `separator=" und"` — renders
250/// `"zwölf Euro und vierunddreißig Cent"` with no comma, while `en`, which keeps
251/// the base `","`, renders `"twelve euros, thirty-four cents"`. Both are diffed
252/// through a core call passing `","`. So a language that overrides `to_currency`
253/// with its own default must restore that default when it sees the sentinel.
254///
255/// The one unreproducible case is a caller explicitly passing `separator=","`,
256/// which gets `" "` where Python would give `","`. That is unresolvable without
257/// an `Option<&str>` on the trait; see the module-level "Known divergences".
258const SEPARATOR_UNSET: &str = ",";
259
260/// Python's `int(parts[1][:2].ljust(2, "0"))` divisor: SD slices exactly two
261/// fractional digits out of `str(val)`, hardcoding hundredths regardless of
262/// `CURRENCY_PRECISION`. See divergence 2.
263const CENTS_DIVISOR: i64 = 100;
264
265pub struct LangSd {
266    /// `self.CURRENCY_FORMS`. Built once in [`LangSd::new`] and cached by the
267    /// generated registry's `OnceLock`, never per call.
268    forms: HashMap<&'static str, CurrencyForms>,
269    /// `list(self.CURRENCY_FORMS.values())[0]` — the entry `to_currency` falls
270    /// back to for an unknown code. Python evaluates that on the dict's
271    /// *insertion* order, which a `HashMap` does not preserve, so the first
272    /// entry (PKR) is resolved once here and stored outright rather than
273    /// recovered from `forms` at lookup time.
274    fallback_forms: CurrencyForms,
275}
276
277impl LangSd {
278    pub fn new() -> Self {
279        // Insertion order in lang_SD.py is PKR, USD, EUR — PKR first, which is
280        // what `list(...)[0]` picks up as the unknown-code fallback.
281        let pkr = CurrencyForms::new(&[RUPEE_SG, RUPEE_PL], &[PAISA_SG, PAISA_PL]);
282        let mut forms = HashMap::new();
283        forms.insert("PKR", pkr.clone());
284        forms.insert(
285            "USD",
286            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
287        );
288        forms.insert(
289            "EUR",
290            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
291        );
292        LangSd {
293            forms,
294            fallback_forms: pkr,
295        }
296    }
297}
298
299impl Default for LangSd {
300    fn default() -> Self {
301        Self::new()
302    }
303}
304
305/// Python's `_int_to_word`.
306///
307/// The `number < 0` arm is faithfully reproduced even though it is **dead code
308/// on every in-scope path**: `to_cardinal` strips the sign from the string form
309/// before calling in, and the recursive call sites all pass a positive
310/// remainder. It would only matter to a caller reaching `_int_to_word` directly.
311fn int_to_word(number: &BigInt) -> String {
312    if number.is_zero() {
313        return ZERO.to_string();
314    }
315    if number.is_negative() {
316        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
317    }
318    // Every branch below 10^9 is bounded, so a u64 is provably wide enough
319    // once we know the value is under the threshold. Anything else — including
320    // BigInts too large for u64 at all — takes the digit fallback (bug 3).
321    match number.to_u64() {
322        Some(n) if n < FALLBACK_THRESHOLD => bounded_to_word(n),
323        _ => number.to_string(),
324    }
325}
326
327/// The `1 <= number < 10^9` portion of `_int_to_word`.
328///
329/// Invariant: never called with 0. Python's zero case is handled one level up
330/// in [`int_to_word`], and each recursion here is guarded by Python's
331/// `if remainder:` / a nonzero quotient. Were it called with 0 it would return
332/// `ONES[0]` (`""`) rather than [`ZERO`] — the guard is what keeps bug 1's
333/// English "zero" confined to the top-level entry point.
334fn bounded_to_word(number: u64) -> String {
335    if number < 10 {
336        return ONES[number as usize].to_string();
337    }
338
339    if number < 100 {
340        let tens_val = (number / 10) as usize;
341        let ones_val = (number % 10) as usize;
342        // No teens special case: 11 becomes "ten one". See bug 2.
343        if ones_val == 0 {
344            return TENS[tens_val].to_string();
345        }
346        return format!("{} {}", TENS[tens_val], ONES[ones_val]);
347    }
348
349    if number < 1_000 {
350        // Note: hundreds use `ones[h]` directly, not a recursive call.
351        let hundreds_val = (number / 100) as usize;
352        let remainder = number % 100;
353        let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
354        if remainder != 0 {
355            result.push(' ');
356            result.push_str(&bounded_to_word(remainder));
357        }
358        return result;
359    }
360
361    if number < 1_000_000 {
362        let thousands_val = number / 1_000;
363        let remainder = number % 1_000;
364        let mut result = format!("{} {}", bounded_to_word(thousands_val), THOUSAND);
365        if remainder != 0 {
366            result.push(' ');
367            result.push_str(&bounded_to_word(remainder));
368        }
369        return result;
370    }
371
372    // number < 10^9, guaranteed by the caller.
373    let millions_val = number / 1_000_000;
374    let remainder = number % 1_000_000;
375    let mut result = format!("{} {}", bounded_to_word(millions_val), MILLION);
376    if remainder != 0 {
377        result.push(' ');
378        result.push_str(&bounded_to_word(remainder));
379    }
380    result
381}
382
383impl Lang for LangSd {
384
385    fn cardinal_float_entry(
386        &self,
387        value: &crate::floatpath::FloatValue,
388        precision_override: Option<u32>,
389    ) -> crate::base::Result<String> {
390        // Python's to_cardinal routes every float/Decimal through this
391        // language's own decimal grammar — 5.0 keeps its ".0" tail
392        // ("comma nulla"), unlike Base's whole-value integer route.
393        self.to_cardinal_float(value, precision_override)
394    }
395
396    /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
397    /// `to_cardinal(number) + "-و"` for *any* input (no
398    /// `verify_ordinal`), so the float path is the float cardinal put through
399    /// the same literal transformation: `5.0` -> "پنج point zero-و".
400    /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
401    /// before the transformation, exactly as in Python.
402    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
403        let cardinal = self.cardinal_float_entry(value, None)?;
404        Ok(format!("{}-و", cardinal))
405    }
406
407    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. `repr_str` is the
408    /// dispatcher's exact `str(value)` (float repr / `Decimal.__str__`), so
409    /// trailing zeros and `1E+2`-style exponent forms survive verbatim.
410    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
411        Ok(format!("{}.", repr_str))
412    }
413
414    /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
415    /// Infinity sentinel becomes the ValueError this language's own
416    /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
417    /// fails); the shared dispatcher would otherwise report Base's
418    /// OverflowError. NaN keeps the base sentinel: the dispatcher's
419    /// ValueError for it already matches `int("NaN")`.
420    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
421        match crate::strnum::python_decimal_parse(s)? {
422            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
423                "invalid literal for int() with base 10: 'Infinity'".into(),
424            )),
425            p => Ok(p),
426        }
427    }
428
429    /// This language's own `to_currency(currency=...)` default,
430    /// read from the live Python signature. Only 44 of 156 use EUR.
431    fn default_currency(&self) -> &str {
432        "PKR"
433    }
434
435    /// This language's own `to_currency(separator=...)` default,
436    /// read from the live Python signature. Base's is ",", but only
437    /// 36 of 149 languages actually use it — most default to " " or a
438    /// conjunction, so inheriting Base's comma silently corrupts them.
439    fn default_separator(&self) -> &str {
440        " "
441    }
442
443    fn negword(&self) -> &str {
444        NEGWORD
445    }
446
447    /// `self.pointword`. Consulted on the `"." in n` branch of Python's
448    /// `to_cardinal` — the float/Decimal path now served by
449    /// [`LangSd::to_cardinal_float`] below. Plain ASCII "point".
450    fn pointword(&self) -> &str {
451        POINTWORD
452    }
453
454    /// Python's `to_cardinal`.
455    ///
456    /// The original works on `str(number).strip()`, peels a leading `"-"` into
457    /// `ret = self.negword`, then re-parses with `int(n)`. For integer input
458    /// that round trip is exactly "take the absolute value and remember the
459    /// sign", which is what this does. The `"." in n` fractional branch is
460    /// unreachable here (scope is integers) and is omitted.
461    ///
462    /// The trailing `.strip()` is preserved, though it is a no-op in practice:
463    /// `_int_to_word` never returns a value with outer whitespace, and it never
464    /// returns `""` (zero yields [`ZERO`]), so there is no case where the
465    /// `"minus "` prefix is left dangling with a space to trim.
466    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
467        let (prefix, magnitude) = if value.is_negative() {
468            (NEGWORD, value.abs())
469        } else {
470            ("", value.clone())
471        };
472        Ok(format!("{}{}", prefix, int_to_word(&magnitude))
473            .trim()
474            .to_string())
475    }
476
477    /// Python's `to_ordinal`: the cardinal with `"-و"` glued on. There is no
478    /// per-value inflection — every ordinal, including "zero-و" and the
479    /// digits-fallback "1000000000-و", takes the same suffix.
480    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
481        Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
482    }
483
484    /// Python's `to_ordinal_num`: `str(number) + "."`. Overrides the base
485    /// default (which omits the dot). Negatives keep their sign: `-1` -> `"-1."`.
486    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
487        Ok(format!("{}.", value))
488    }
489
490    /// Python's `to_year`: delegates straight to `to_cardinal`, discarding the
491    /// `longval` flag. No era suffix, no two-digit pairing — 1999 is read as
492    /// the plain cardinal "one thousand nine hundred ninety nine", and negative
493    /// years just get the "minus " prefix rather than a BC marker.
494    fn to_year(&self, value: &BigInt) -> Result<String> {
495        self.to_cardinal(value)
496    }
497
498    /// Python's `Num2Word_SD.to_cardinal` for **non-integer** input — the
499    /// `"." in n` branch. The Rust trait splits the integer and float entry
500    /// points that Python's single `to_cardinal` serves off one method, so this
501    /// reproduces only the fractional branch:
502    ///
503    /// ```python
504    /// n = str(number).strip()
505    /// if n.startswith("-"):
506    ///     n = n[1:]; ret = self.negword          # "minus "
507    /// else:
508    ///     ret = ""
509    /// left, right = n.split(".", 1)
510    /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
511    /// for digit in right:
512    ///     ret += self._int_to_word(int(digit)) + " "
513    /// return ret.strip()
514    /// ```
515    ///
516    /// # Recovering `left`/`right` without `str(number)`
517    ///
518    /// SD reads the digits straight out of `str(number)`: `left` is the integer
519    /// part, `right` the fractional characters, one Sindhi word per digit. The
520    /// f64 crosses the FFI boundary as a raw double, so the original repr string
521    /// is gone — but `floatpath::float2tuple` reconstructs exactly the same
522    /// `(pre, post)` the corpus is built on, and its zero-padded `post` is byte
523    /// for byte the `right` string SD iterates, load-bearing f64 artefacts and
524    /// all: `2.675` -> `674.9999999999998` rescued to `675`, and the leading
525    /// zeros of `0.01` -> `"01"`. `int(left)` is `pre.abs()` — SD peels the sign
526    /// off the *string* first, so `left` never carries a minus, and the
527    /// "minus " prefix is added separately (bug 5: English word, prepended to
528    /// RTL text). The same `float2tuple` serves the Decimal arm, so `cardinal_dec`
529    /// rows come out right too: `1.10` keeps its trailing zero (`post` = 10 padded
530    /// to `"10"`), and `98746251323029.99` overflows `int_to_word` into bug 3's
531    /// bare-digit fallback for the integer part.
532    ///
533    /// # `precision=` is ignored, exactly as Python ignores it
534    ///
535    /// SD overrides `to_cardinal`, which takes no `precision` argument, so
536    /// `precision=1` still leaves `2.675` at three fractional words. Verified in
537    /// the live interpreter. `_precision_override` is therefore discarded; the
538    /// natural repr-derived `value.precision()` always wins.
539    ///
540    /// # `ValueError` on inputs `int()` cannot parse
541    ///
542    /// SD reads `str(number)`; when that carries exponent notation the `int()`
543    /// calls throw `ValueError`. `str(1e16) == "1e+16"` has no `"."`, so the
544    /// *integer* branch runs `int("1e+16")`; `str(1.5e-5) == "1.5e-05"` keeps a
545    /// `"."` but then `int("5e-05")` throws in the digit loop. Non-finite floats
546    /// (`inf`/`nan`, `str` `"inf"`/`"nan"`) throw the same way. The message is
547    /// never observed — only the exception *type* — so a plain `N2WError::Value`
548    /// suffices. The predicates below reproduce CPython's `repr`/`Decimal.__str__`
549    /// exponent thresholds exactly (fuzzed 500k floats / 200k Decimals, zero
550    /// divergence): a float switches to `e`-notation iff `x != 0 and
551    /// (|x| >= 1e16 or |x| < 1e-4)`; a Decimal iff its exponent is positive or
552    /// its adjusted exponent `< -6`. No corpus row reaches either — SD's corpus
553    /// floats sit in `[0.01, ~1e14]` — so this only tightens fidelity for inputs
554    /// the harness never generates.
555    ///
556    /// # The negative-zero hole (Decimal only)
557    ///
558    /// `str(Decimal("-0.0")) == "-0.0"` keeps the sign, so Python answers
559    /// "minus zero point zero"; a `BigDecimal` has no signed zero (its `BigInt`
560    /// mantissa normalises `-0` to `0`), and the discriminating string is not
561    /// carried across the `FloatValue::Decimal` boundary, so this arm drops the
562    /// negword. Out of this file's remit — same boundary hole `lang_pa` flags.
563    /// The float arm has no such hole: `FloatValue::Float` keeps the raw f64, so
564    /// `is_sign_negative()` recovers `-0.0`'s minus faithfully.
565    fn to_cardinal_float(
566        &self,
567        value: &FloatValue,
568        _precision_override: Option<u32>,
569    ) -> Result<String> {
570        // Sign is str(number).startswith("-"); also reject the str() forms
571        // int() cannot parse (exponent notation / non-finite), which SD raises
572        // ValueError on. Digits still come from float2tuple, proven byte-for-byte
573        // equal to str(number)'s fractional characters across a 240k fuzz.
574        let negative = match value {
575            FloatValue::Float { value: f, .. } => {
576                if !f.is_finite() || (*f != 0.0 && (f.abs() >= 1e16 || f.abs() < 1e-4)) {
577                    return Err(N2WError::Value(
578                        "invalid literal for int() with base 10".to_string(),
579                    ));
580                }
581                // is_sign_negative, not `< 0.0`: str(-0.0) == "-0.0" keeps its
582                // minus, and SD peels that into a leading negword.
583                f.is_sign_negative()
584            }
585            FloatValue::Decimal { value: d, .. } => {
586                // str(Decimal) uses E-notation when the exponent is positive or
587                // the adjusted exponent < -6; int() then throws ValueError.
588                let (int_val, scale) = d.as_bigint_and_exponent();
589                let ndigits = int_val.abs().to_string().len() as i64;
590                if scale < 0 || (ndigits - 1 - scale) < -6 {
591                    return Err(N2WError::Value(
592                        "invalid literal for int() with base 10".to_string(),
593                    ));
594                }
595                // Negative-zero hole: BigDecimal cannot carry "-0.0"'s sign.
596                d.is_negative()
597            }
598        };
599
600        let precision = value.precision() as usize;
601        let (pre, post) = float2tuple(value);
602
603        // `ret = self.negword if n.startswith("-") else ""`, then
604        // `self._int_to_word(int(left))` on the *unsigned* integer part.
605        let mut ret = String::new();
606        if negative {
607            ret.push_str(NEGWORD);
608        }
609        ret.push_str(&int_to_word(&pre.abs()));
610
611        if precision > 0 {
612            // `+ " " + self.pointword + " "`
613            ret.push(' ');
614            ret.push_str(POINTWORD);
615            ret.push(' ');
616
617            // `right`: the fractional characters of str(number) — `post`,
618            // left-padded with zeros to `precision` digits (post < 10**precision).
619            // Mirrors floatpath::default_to_cardinal_float's own padding.
620            let post_str = post.to_string();
621            let padding = "0".repeat(precision.saturating_sub(post_str.len()));
622            for ch in format!("{}{}", padding, post_str).chars().take(precision) {
623                // Each char is one ASCII digit; `self._int_to_word(int(digit))`
624                // maps 0 -> "zero" (bug 1) and 1..=9 -> ONES[d].
625                let d = ch.to_digit(10).unwrap_or(0);
626                ret.push_str(&int_to_word(&BigInt::from(d)));
627                ret.push(' ');
628            }
629        }
630
631        // `return ret.strip()` — drops the trailing digit space. (It would also
632        // trim a dangling "minus " space, but no reachable path leaves one:
633        // int_to_word never returns "" — zero yields "zero".)
634        Ok(ret.trim().to_string())
635    }
636
637    // ---- currency ------------------------------------------------------
638
639    /// For the `Currency code "X" not implemented for "Num2Word_SD"` message
640    /// that `to_cheque` raises. `to_currency` never raises it — see below.
641    fn lang_name(&self) -> &str {
642        "Num2Word_SD"
643    }
644
645    /// `self.CURRENCY_FORMS[code]`.
646    ///
647    /// Deliberately returns `None` — not the PKR fallback — for an unknown code.
648    /// The fallback is a quirk of SD's *own* `to_currency` (which uses
649    /// `dict.get(code, default)`); the inherited `to_cheque` uses plain
650    /// `self.CURRENCY_FORMS[currency]` subscripting and lets the `KeyError`
651    /// become a `NotImplementedError`. Folding the fallback in here would make
652    /// `to_cheque("GBP")` succeed with rupees where Python raises.
653    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
654        self.forms.get(code)
655    }
656
657    /// Python's `Num2Word_SD.to_currency` — an outright override of the base
658    /// implementation that shares almost none of its structure. It parses
659    /// `str(val)` textually instead of going through `parse_currency_parts`,
660    /// which is the source of most of the divergences listed below.
661    ///
662    /// ```python
663    /// def to_currency(self, val, currency="PKR", cents=True,
664    ///                 separator=" ", adjective=False):
665    ///     is_negative = False
666    ///     if val < 0:
667    ///         is_negative = True
668    ///         val = abs(val)
669    ///     parts = str(val).split(".")
670    ///     left = int(parts[0]) if parts[0] else 0
671    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
672    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
673    ///         currency, list(self.CURRENCY_FORMS.values())[0])
674    ///     left_str = self._int_to_word(left)
675    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
676    ///     if cents and right:
677    ///         cents_str = self._int_to_word(right)
678    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
679    ///     if is_negative:
680    ///         result = self.negword + result
681    ///     return result.strip()
682    /// ```
683    ///
684    /// Note it calls `_int_to_word`, not `to_cardinal`. For the non-negative
685    /// `left`/`right` this path can produce, the two agree.
686    ///
687    /// # Why the `Int`/`Decimal` split does not branch here
688    ///
689    /// `Num2Word_Base.to_currency` keys off `isinstance(val, int)` to decide
690    /// whether to print cents. SD never does — it asks whether `str(val)` has a
691    /// fractional component, then whether that component is truthy *as an int*
692    /// (as do the other 62 languages that hand-roll this same `str(val).split(".")`
693    /// override). For a float
694    /// `1.0` that yields `parts[1] == "0"` -> `right == 0` -> falsy -> cents are
695    /// skipped, exactly as for the int `1`. So Python itself collapses the
696    /// distinction here, and the corpus agrees: `arg "1"` and `arg "1.0"` both
697    /// give `"هڪ euro"`. The match below still keeps the variants apart, because
698    /// they reach the same `(left, right)` by different arithmetic, not because
699    /// the outputs differ.
700    ///
701    /// # `right` is a truncating two-digit slice
702    ///
703    /// `int(parts[1][:2].ljust(2, "0"))` takes the first two fractional
704    /// characters and pads *right* with zeros — i.e. it reads hundredths and
705    /// truncates the rest. `trunc(fraction * 100)` is the exact arithmetic
706    /// equivalent for any plain-decimal `str(val)`:
707    ///
708    /// | `str(val)` | `parts[1]` | slice+pad | `trunc(frac*100)` |
709    /// |---|---|---|---|
710    /// | `12.34`  | `"34"`  | `"34"` -> 34 | 34 |
711    /// | `0.5`    | `"5"`   | `"50"` -> 50 | 50 |
712    /// | `1.0`    | `"0"`   | `"00"` -> 0  | 0  |
713    /// | `12.345` | `"345"` | `"34"` -> 34 | 34 |
714    /// | `12.005` | `"005"` | `"00"` -> 0  | 0  |
715    /// | `12.999` | `"999"` | `"99"` -> 99 | 99 |
716    ///
717    /// All six verified against the live interpreter. Note the last three: there
718    /// is **no rounding anywhere** — `12.999` bills 99 cents, and `2.675` bills
719    /// 67, not the 68 that `ROUND_HALF_UP` would give in the base path.
720    fn to_currency(
721        &self,
722        val: &CurrencyValue,
723        currency: &str,
724        cents: bool,
725        separator: Option<&str>,
726        _adjective: bool,
727    ) -> Result<String> {
728        // Trait now hands us None when the caller omitted separator=;
729        // resolve it to this language's own default before the ported body.
730        let separator = separator.unwrap_or(self.default_separator());
731        // Restore SD's own `separator=" "` default; see SEPARATOR_UNSET.
732        let separator = if separator == SEPARATOR_UNSET {
733            SD_SEPARATOR
734        } else {
735            separator
736        };
737
738        // `if val < 0: is_negative = True; val = abs(val)`
739        let is_negative = val.is_negative();
740
741        let (left, right) = match val {
742            // `str(int)` has no ".", so `len(parts) > 1` is false and `right`
743            // stays 0 — a pure int never shows cents.
744            CurrencyValue::Int(i) => (i.abs(), BigInt::zero()),
745            CurrencyValue::Decimal { value: d, .. } => {
746                let d = if is_negative { d.abs() } else { d.clone() };
747                // `int(parts[0])`: the integer part, truncated.
748                let whole = d.with_scale(0);
749                let fraction = &d - &whole;
750                let cents_val = (fraction * BigDecimal::from(CENTS_DIVISOR)).with_scale(0);
751                (
752                    whole.as_bigint_and_exponent().0,
753                    cents_val.as_bigint_and_exponent().0,
754                )
755            }
756        };
757
758        // `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
759        // — an unknown code silently renders as Pakistani rupees rather than
760        // raising. The corpus leans on this hard: GBP/JPY/KWD/BHD/INR/CNY/CHF
761        // are all absent from SD's table and every one of their rows comes back
762        // in rupees and paisas.
763        let forms = self.forms.get(currency).unwrap_or(&self.fallback_forms);
764
765        let one = BigInt::one();
766        // `cr1[1] if left != 1 else cr1[0]` — note 0 takes the plural, hence
767        // the corpus's "zero euros".
768        let unit = if left != one {
769            &forms.unit[1]
770        } else {
771            &forms.unit[0]
772        };
773        let mut result = format!("{} {}", int_to_word(&left), unit);
774
775        // `if cents and right:` — `right == 0` is falsy, so a float with zero
776        // cents drops the segment entirely. Also note that `cents=False` does
777        // *not* fall back to terse digits the way the base class does: it
778        // suppresses the cents segment outright. `_cents_terse` is unreachable.
779        if cents && !right.is_zero() {
780            let subunit = if right != one {
781                &forms.subunit[1]
782            } else {
783                &forms.subunit[0]
784            };
785            // `result += separator + cents_str + " " + ...` — no space before
786            // the separator; the space in SD_SEPARATOR is the only one.
787            result.push_str(separator);
788            result.push_str(&int_to_word(&right));
789            result.push(' ');
790            result.push_str(subunit);
791        }
792
793        // `result = self.negword + result` — "minus " (English, prepended to
794        // RTL text; see bug 5).
795        if is_negative {
796            result.insert_str(0, NEGWORD);
797        }
798
799        // `result.strip()`. A no-op on every reachable path: `_int_to_word`
800        // never returns leading/trailing whitespace and never returns "".
801        Ok(result.trim().to_string())
802    }
803}
804
805#[cfg(test)]
806mod float_tests {
807    use super::*;
808    use bigdecimal::BigDecimal;
809    use std::str::FromStr;
810
811    /// precision == abs(Decimal(repr(f)).as_tuple().exponent), computed on the
812    /// Python side by the binding. Hardcoded here to the values verified in the
813    /// live interpreter.
814    fn flt(value: f64, precision: u32) -> String {
815        LangSd::new()
816            .to_cardinal_float(&FloatValue::Float { value, precision }, None)
817            .unwrap()
818    }
819
820    fn dec(s: &str, precision: u32) -> String {
821        LangSd::new()
822            .to_cardinal_float(
823                &FloatValue::Decimal {
824                    value: BigDecimal::from_str(s).unwrap(),
825                    precision,
826                },
827                None,
828            )
829            .unwrap()
830    }
831
832    #[test]
833    fn corpus_float_rows() {
834        // Every `"lang":"sd","to":"cardinal"` row with a dot in `arg`.
835        assert_eq!(flt(0.0, 1), "zero point zero");
836        assert_eq!(flt(0.5, 1), "zero point پنج");
837        assert_eq!(flt(1.0, 1), "هڪ point zero");
838        assert_eq!(flt(1.5, 1), "هڪ point پنج");
839        assert_eq!(flt(2.25, 2), "ٻه point ٻه پنج");
840        assert_eq!(flt(3.14, 2), "ٽي point هڪ چار");
841        assert_eq!(flt(0.01, 2), "zero point zero هڪ");
842        assert_eq!(flt(0.1, 1), "zero point هڪ");
843        assert_eq!(flt(0.99, 2), "zero point نو نو");
844        assert_eq!(flt(1.01, 2), "هڪ point zero هڪ");
845        assert_eq!(flt(12.34, 2), "ڏهه ٻه point ٽي چار");
846        assert_eq!(flt(99.99, 2), "نوي نو point نو نو");
847        assert_eq!(flt(100.5, 1), "هڪ سو point پنج");
848        assert_eq!(flt(1234.56, 2), "هڪ هزار ٻه سو ٽيهه چار point پنج ڇهه");
849        assert_eq!(flt(-0.5, 1), "minus zero point پنج");
850        assert_eq!(flt(-1.5, 1), "minus هڪ point پنج");
851        assert_eq!(flt(-12.34, 2), "minus ڏهه ٻه point ٽي چار");
852        assert_eq!(flt(1.005, 3), "هڪ point zero zero پنج");
853        assert_eq!(flt(2.675, 3), "ٻه point ڇهه ست پنج"); // f64 artefact -> 675
854        // extra live-interpreter checks
855        assert_eq!(flt(2.0, 1), "ٻه point zero");
856        assert_eq!(flt(1000000.5, 1), "هڪ لک point پنج");
857    }
858
859    #[test]
860    fn corpus_decimal_rows() {
861        // Every `"lang":"sd","to":"cardinal_dec"` row.
862        assert_eq!(dec("0.01", 2), "zero point zero هڪ");
863        assert_eq!(dec("1.10", 2), "هڪ point هڪ zero"); // trailing zero kept
864        assert_eq!(dec("12.345", 3), "ڏهه ٻه point ٽي چار پنج");
865        assert_eq!(dec("98746251323029.99", 2), "98746251323029 point نو نو"); // bug 3 fallback
866        assert_eq!(dec("0.001", 3), "zero point zero zero هڪ");
867    }
868
869    #[test]
870    fn precision_override_ignored() {
871        // Python's Num2Word_SD.to_cardinal takes no precision arg — 2.675 keeps
872        // its three fractional words no matter what precision= is passed.
873        let sd = LangSd::new();
874        let v = FloatValue::Float { value: 2.675, precision: 3 };
875        assert_eq!(
876            sd.to_cardinal_float(&v, Some(1)).unwrap(),
877            "ٻه point ڇهه ست پنج"
878        );
879    }
880
881    #[test]
882    fn float_negative_zero_keeps_negword() {
883        // str(-0.0) == "-0.0" -> Python prepends the negword; is_sign_negative
884        // recovers it where `< 0.0` would not.
885        assert_eq!(flt(-0.0, 1), "minus zero point zero");
886    }
887
888    #[test]
889    fn scientific_and_nonfinite_raise_value() {
890        let sd = LangSd::new();
891        // str(1e16) == "1e+16" -> int("1e+16") raises ValueError.
892        for (v, p) in [(1e16, 17u32), (1e-5, 5), (1.5e-5, 6), (f64::INFINITY, 0)] {
893            assert!(matches!(
894                sd.to_cardinal_float(&FloatValue::Float { value: v, precision: p }, None),
895                Err(N2WError::Value(_))
896            ));
897        }
898        // str(Decimal("1E-7")) == "1E-7" (adjusted exp -7 < -6) -> ValueError.
899        assert!(matches!(
900            sd.to_cardinal_float(
901                &FloatValue::Decimal {
902                    value: BigDecimal::from_str("0.0000001").unwrap(),
903                    precision: 7,
904                },
905                None,
906            ),
907            Err(N2WError::Value(_))
908        ));
909    }
910}