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}