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}