num2words2_core/lang_bo.rs
1//! Port of `lang_BO.py` (Tibetan).
2//!
3//! Shape: **self-contained**. `Num2Word_BO` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
5//! `Num2Word_Base.__init__` never builds `self.cards` and never sets
6//! `MAXVAL`. `to_cardinal` is overridden outright and drives a recursive
7//! `_int_to_word`. Consequently `cards`/`maxval`/`merge` stay at their trait
8//! defaults here, and there is **no overflow check and no ceiling at all** —
9//! the top branch of `_int_to_word` recurses on `number // 10**8`
10//! indefinitely, stacking "དུང་ཕྱུར་" once per 10^8 factor. `to_cardinal(10**21)`
11//! is therefore "གཅིག་འབུམ་དུང་ཕྱུར་དུང་ཕྱུར་", not an `OverflowError`.
12//!
13//! `setup()` sets `negword = "མེད་ཆ་ "` (note the **trailing ASCII space**, which
14//! is part of the literal) and `pointword = "ཚེག་"`.
15//!
16//! Inherited from `Num2Word_Base` but overridden by BO, so the trait defaults
17//! are *not* used for any of the four in-scope modes:
18//! * `to_cardinal` — own algorithm (below).
19//! * `to_ordinal(n)` → `to_cardinal(n) + "པ་"`.
20//! * `to_ordinal_num(n)` → `str(n) + "པ"` (BO overrides the base, which
21//! returns the value unchanged).
22//! * `to_year(n)` → `"སྤྱི་ལོ་" + to_cardinal(n)` (BO overrides the base, which
23//! delegates to `to_cardinal`).
24//!
25//! # Behaviour worth flagging (reproduced verbatim, not fixed)
26//!
27//! 1. **No `verify_ordinal` call.** `Num2Word_Base.to_ordinal` would normally
28//! be guarded by `verify_ordinal`, which raises `TypeError` on negatives.
29//! BO's override skips it entirely, so `to_ordinal(-1000000)` happily
30//! returns "མེད་ཆ་ གཅིག་ས་ཡ་པ་" — the negative-marker prefix with an ordinal
31//! suffix glued on. Confirmed against the frozen corpus.
32//! 2. **`to_year` prepends its era marker *outside* the sign.** Because
33//! `to_year` is `"སྤྱི་ལོ་" + self.to_cardinal(val)` and `to_cardinal` emits the
34//! negword itself, a negative year reads "སྤྱི་ལོ་" then "མེད་ཆ་ " — i.e.
35//! `to_year(-500)` == "སྤྱི་ལོ་མེད་ཆ་ ལྔ་བརྒྱ་", with the era word preceding the
36//! minus marker. Confirmed against the frozen corpus.
37//! 3. **Asymmetric multiplier handling.** The `< 1000` branch indexes `ones`
38//! directly (`ones[number // 100]`) while every branch above it recurses
39//! (`self._int_to_word(number // 1000)` etc.). This is why 100 is
40//! "གཅིག་བརྒྱ་" (an explicit "one hundred") and why the >= 10^8 branch can
41//! nest "དུང་ཕྱུར་" arbitrarily deep.
42//! 4. **`negword` is used raw, not stripped.** BO's `to_cardinal` does
43//! `ret = self.negword` where `Num2Word_Base.to_cardinal` would do
44//! `"%s " % self.negword.strip()`. Both happen to yield "མེད་ཆ་ " here
45//! because the literal already carries exactly one trailing space, but the
46//! literal is what is reproduced below.
47//!
48//! # The float / Decimal path
49//!
50//! BO does **not** override `to_cardinal_float`; it overrides `to_cardinal`
51//! and handles non-integers inline. So `Num2Word_Base.to_cardinal_float` —
52//! and with it `float2tuple` — is **never reached**, and none of that
53//! machinery applies here:
54//!
55//! * **No `float2tuple`, so no f64 artefacts to preserve.** The base class
56//! computes `post = abs(value - pre) * 10**precision` in binary f64 and
57//! leans on a `< 0.01` heuristic to rescue `2.675` from `674.9999999999998`.
58//! BO never does any float arithmetic at all: it reads the *digits of
59//! `str(number)`*. `2.675` is "two point six seven five" because the
60//! characters `6`, `7`, `5` are literally in `repr(2.675)`, not because a
61//! rounding heuristic recovered them. `1.005` likewise.
62//! * **No `round()`, so no banker's-rounding trap.** There is no rounding
63//! anywhere on this path.
64//! * **`self.precision` is never read**, so the `precision=` kwarg (issue
65//! #580) is inert: `num2words(2.675, lang="bo", precision=1)` still emits
66//! all three fractional digits. `precision_override` is accordingly ignored
67//! — see [`LangBo::to_cardinal_float`].
68//!
69//! What the path *does* need is `str(number)`, byte for byte, because every
70//! branch keys off it: the `"-"` prefix, the `"."`, and one word per
71//! character of the fractional part. That makes the two `FloatValue` arms
72//! genuinely different functions — `repr(float)` and `Decimal.__str__` are
73//! separate CPython routines with separate exponent-notation rules — so
74//! [`python_float_repr`] and [`python_decimal_str`] reproduce them
75//! separately. Both are reconstructions, and their limits are documented on
76//! each.
77//!
78//! Trailing zeros are the clearest proof that this is a string path rather
79//! than a numeric one: `Decimal("1.10")` renders as "one point one zero"
80//! because `str()` kept the zero, while `1.10` as a *float* renders as "one
81//! point one" because `repr()` dropped it. A numeric port collapses the two.
82//!
83//! # Error variants
84//!
85//! For the four integer modes: none. Every in-scope path is total for integer
86//! input: `_int_to_word` only ever indexes `ones`/`tens` with a value it has
87//! already bounded to 0..=9, and the sign is stripped before recursion begins,
88//! so no `IndexError`, `KeyError` or `ValueError` site exists. There is no
89//! `MAXVAL`, hence no `OverflowError` either.
90//!
91//! The currency surface adds two, both detailed below: `NotImplementedError`
92//! from `to_cheque` on an unknown code, and `ValueError` from `_split_currency`
93//! when `str(val)` came out in exponent notation.
94//!
95//! The float path adds one more, for the same underlying reason: `int()` is
96//! fed raw characters of `str(number)`, so any value whose `str()` went
97//! scientific raises **`ValueError`** rather than converting. See
98//! [`int_token`].
99//!
100//! # The currency surface
101//!
102//! `Num2Word_BO` declares `CURRENCY_FORMS` for exactly three codes (CNY, USD,
103//! EUR) and overrides `to_currency` outright. It inherits `CURRENCY_ADJECTIVES`
104//! and `CURRENCY_PRECISION` from `Num2Word_Base`, where **both are empty** — so
105//! `CURRENCY_PRECISION.get(code, 100)` is 100 for *every* code and the trait
106//! defaults for `currency_precision`/`currency_adjective` are already correct.
107//! This is why BO has no 3-decimal or 0-decimal behaviour: `to_currency("KWD")`
108//! divides by 100, not 1000, and `to_currency("JPY", 12.34)` still renders a
109//! cents segment rather than rounding to a whole unit. Confirmed against the
110//! frozen corpus for both codes.
111//!
112//! `to_cheque` is *not* overridden, so `Num2Word_Base.to_cheque` runs verbatim
113//! — which means the two halves of the surface disagree about unknown codes:
114//!
115//! * `to_currency` does `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["CNY"])`
116//! and so **silently falls back to CNY** — `to_currency(1, currency="GBP")`
117//! is "གཅིག་ སྒོར་", never an error.
118//! * `to_cheque` does `CURRENCY_FORMS[currency]` and so raises
119//! `NotImplementedError` for the very same code.
120//!
121//! Both are reproduced: [`LangBo::to_currency`] resolves the fallback itself,
122//! while `currency_forms` returns `None` for an unknown code so that the
123//! trait's default `to_cheque` (== `default_to_cheque`, a faithful port of the
124//! base method) raises. Corpus: `currency:GBP` succeeds with CNY words,
125//! `cheque:GBP` raises `NotImplementedError`.
126//!
127//! ## `parse_currency` does not exist
128//!
129//! `Num2Word_BO.to_currency` opens with
130//!
131//! ```text
132//! try:
133//! left, right, is_negative = self.parse_currency(val)
134//! except AttributeError:
135//! ...
136//! ```
137//!
138//! No `parse_currency` method is defined anywhere in num2words2 — not on
139//! `Num2Word_BO`, not on `Num2Word_Base` (`currency.py` exports the free
140//! function `parse_currency_parts`, which is a different name and never bound
141//! to the instance). The attribute lookup therefore *always* raises
142//! `AttributeError`, the `try` body never completes, and the `except` branch is
143//! the only live path. It is ported as straight-line code with no error plumbing,
144//! because the exception is unconditional and always swallowed.
145
146use crate::base::{Lang, N2WError, Result};
147use crate::currency::{CurrencyForms, CurrencyValue};
148use crate::floatpath::FloatValue;
149use crate::strnum::{python_decimal_parse, ParsedNumber};
150use bigdecimal::BigDecimal;
151use num_bigint::BigInt;
152use num_traits::{Signed, ToPrimitive, Zero};
153use std::collections::HashMap;
154
155/// `_int_to_word`'s early return for 0.
156const ZERO_WORD: &str = "ཀླད་ཀོར་";
157
158/// `setup(): self.negword` — the trailing ASCII space is part of the literal.
159const NEGWORD: &str = "མེད་ཆ་ ";
160
161/// `setup(): self.pointword`.
162const POINTWORD: &str = "ཚེག་";
163
164/// `to_year`'s era prefix.
165const YEAR_PREFIX: &str = "སྤྱི་ལོ་";
166
167/// `Num2Word_BO.to_currency`'s own default `separator=" དང་ "`.
168///
169/// See [`SEPARATOR_UNSET`] for why this cannot simply be a parameter default.
170const SEPARATOR_DEFAULT: &str = " དང་ ";
171
172/// The separator the pyo3 binding passes when the Python caller omitted one.
173///
174/// `Num2Word_BO.to_currency` declares `separator=" དང་ "`, but the `Lang` trait
175/// has no per-language defaults: `__init__.py`'s currency fast path (and
176/// `diff_test.py`) substitute `kwargs.get("separator", ",")` — **`Num2Word_Base`'s**
177/// default — before the value ever reaches Rust. By then "caller omitted
178/// separator" and "caller explicitly passed a comma" are the same string, and
179/// the information needed to tell them apart no longer exists on this side of
180/// the boundary.
181///
182/// So `,` is read back as the unset sentinel and BO's own default restored.
183/// This is the only reading that matches the oracle: every float row of the
184/// `bo` currency corpus was generated by `num2words(v, lang="bo",
185/// to="currency", currency=c)` with no `separator=`, and every one of them
186/// expects " དང་ ".
187///
188/// The cost is narrow and known: a caller who *explicitly* passes
189/// `separator=","` gets " དང་ " here where Python would give ",". Fixing that
190/// properly needs `Option<&str>` in the trait signature, which lives in
191/// `base.rs` — outside this port's remit. Flagged in the port report. This
192/// mirrors the convention `lang_ca.rs` established for the same problem.
193const SEPARATOR_UNSET: &str = ",";
194
195/// `to_currency`'s fallback code: `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["CNY"])`.
196///
197/// Note the Python evaluates `self.CURRENCY_FORMS["CNY"]` *eagerly* as the
198/// `.get` default, so a class that dropped the CNY entry would raise `KeyError`
199/// on every call. BO always has it, so the lookup below is infallible.
200const FALLBACK_CURRENCY: &str = "CNY";
201
202/// `to_ordinal`'s suffix (note the trailing tsheg).
203const ORDINAL_SUFFIX: &str = "པ་";
204
205/// `to_ordinal_num`'s suffix (note: **no** trailing tsheg, unlike `to_ordinal`).
206const ORDINAL_NUM_SUFFIX: &str = "པ";
207
208/// `ones`, index 0..=9. Index 0 is the empty string in Python.
209const ONES: [&str; 10] = [
210 "",
211 "གཅིག་",
212 "གཉིས་",
213 "གསུམ་",
214 "བཞི་",
215 "ལྔ་",
216 "དྲུག་",
217 "བདུན་",
218 "བརྒྱད་",
219 "དགུ་",
220];
221
222/// `tens`, index 0..=9 (the multiple-of-ten words). Index 0 is the empty
223/// string in Python and is unreachable (the `< 10` branch catches it first).
224const TENS: [&str; 10] = [
225 "",
226 "བཅུ་",
227 "ཉི་ཤུ་",
228 "སུམ་ཅུ་",
229 "བཞི་བཅུ་",
230 "ལྔ་བཅུ་",
231 "དྲུག་ཅུ་",
232 "བདུན་ཅུ་",
233 "བརྒྱད་ཅུ་",
234 "དགུ་བཅུ་",
235];
236
237/// The hundreds word, spliced between `ones[number // 100]` and the remainder.
238const HUNDRED: &str = "བརྒྱ་";
239
240/// The scale branches above 1000, in the exact order of Python's `elif` chain.
241///
242/// Each entry is `(limit_exp, div_exp, word)` standing for Python's
243/// `elif number < 10**limit_exp: _int_to_word(number // 10**div_exp) + word +
244/// (_int_to_word(number % 10**div_exp) if number % 10**div_exp else "")`.
245///
246/// The final entry (`10^8`, "དུང་ཕྱུར་") is Python's unguarded `else` arm; its
247/// `limit_exp` is never consulted, which is what makes the recursion
248/// unbounded. See [`int_to_word`].
249const SCALES: [(u32, u32, &str); 6] = [
250 (4, 3, "སྟོང་"), // < 10_000 → thousands
251 (5, 4, "ཁྲི་"), // < 100_000 → ten-thousands
252 (6, 5, "འབུམ་"), // < 1_000_000 → hundred-thousands
253 (7, 6, "ས་ཡ་"), // < 10_000_000 → millions
254 (8, 7, "བྱེ་བ་"), // < 100_000_000 → ten-millions
255 (0, 8, "དུང་ཕྱུར་"), // else (limit unused) → hundred-millions
256];
257
258/// `10**exp` as a `BigInt`.
259fn pow10(exp: u32) -> BigInt {
260 BigInt::from(10u8).pow(exp)
261}
262
263/// Narrow a `BigInt` the caller has already bounded to 0..=999.
264///
265/// Every call site sits behind a `n < 1000` comparison that proves the range,
266/// so the `expect` is unreachable rather than a lurking panic. Below 1000 the
267/// arithmetic is done in `usize`: the values are provably bounded, and
268/// Python's `//`/`%` agree with Rust's `/`/`%` on non-negatives.
269fn small(n: &BigInt) -> usize {
270 n.to_usize().expect("caller proved this value is < 1000")
271}
272
273/// Port of `Num2Word_BO._int_to_word`.
274///
275/// Only ever reached with a non-negative value: `to_cardinal` strips the
276/// leading "-" from the *string* before calling `int()`, so the sign never
277/// arrives here. (Were it to, Python's negative list indexing would silently
278/// wrap — `ones[-1]` == "དགུ་" — but that path is unreachable for the four
279/// in-scope modes.)
280///
281/// Recursion depth is `O(log_1e8(n))`, so even a 10^606 input nests only ~76
282/// deep — no fixed-width cast is needed or made anywhere.
283fn int_to_word(n: &BigInt) -> String {
284 if n.is_zero() {
285 return ZERO_WORD.to_string();
286 }
287
288 // if number < 10: return ones[number]
289 if n < &pow10(1) {
290 return ONES[small(n)].to_string();
291 }
292
293 // elif number < 100:
294 // return tens[number // 10] + (ones[number % 10] if number % 10 else "")
295 if n < &pow10(2) {
296 let v = small(n);
297 let mut out = TENS[v / 10].to_string();
298 if v % 10 != 0 {
299 out.push_str(ONES[v % 10]);
300 }
301 return out;
302 }
303
304 // elif number < 1000:
305 // return ones[number // 100] + "བརྒྱ་"
306 // + (self._int_to_word(number % 100) if number % 100 else "")
307 //
308 // NB: the multiplier is a *direct index* here, not a recursive call —
309 // see bug note 3 in the module docs.
310 if n < &pow10(3) {
311 let v = small(n);
312 let mut out = ONES[v / 100].to_string();
313 out.push_str(HUNDRED);
314 if v % 100 != 0 {
315 out.push_str(&int_to_word(&BigInt::from(v % 100)));
316 }
317 return out;
318 }
319
320 // The remaining `elif` arms share one shape; the last is Python's `else`,
321 // whose limit is never tested — hence the unbounded "དུང་ཕྱུར་" nesting.
322 for (i, &(limit_exp, div_exp, word)) in SCALES.iter().enumerate() {
323 let is_else_arm = i == SCALES.len() - 1;
324 if !is_else_arm && n >= &pow10(limit_exp) {
325 continue;
326 }
327 let d = pow10(div_exp);
328 let q = n / &d;
329 let r = n % &d;
330 let mut out = int_to_word(&q);
331 out.push_str(word);
332 if !r.is_zero() {
333 out.push_str(&int_to_word(&r));
334 }
335 return out;
336 }
337
338 unreachable!("the final SCALES entry is an unconditional else arm")
339}
340
341/// Python's `int(token)`, for the tokens `to_cardinal` actually feeds it.
342///
343/// `to_cardinal` never validates: it hands `int()` whatever slice of
344/// `str(number)` it carved out, and lets the exception escape. The reachable
345/// tokens all come from `repr(float)` or `Decimal.__str__` with at most one
346/// leading `"-"` already peeled off, so their alphabet is
347/// `[0-9] . - + e E i n f a`. Over that alphabet "every character is an ASCII
348/// digit" is exactly Python's accept condition, and everything else raises
349/// `ValueError` with the token quoted verbatim — `'e'`, `'1e+16'`, `'1E-7'`,
350/// `'inf'`, `'nan'`.
351///
352/// The wider `int()` grammar (surrounding whitespace, a `+`/`-` sign,
353/// `1_000` underscores, non-ASCII decimal digits like `'٥'`) is deliberately
354/// *not* implemented: none of it is reachable from a `str()` of a float or a
355/// Decimal, and accepting it would only paper over inputs Python rejects. The
356/// argument does not actually depend on the alphabet being complete — a
357/// `Decimal("Infinity")` would widen it, though `BigDecimal::from_str` rejects
358/// that at the shim before it can — only on none of these sources emitting
359/// the sign/space/underscore forms `int()` accepts and this rejects. None do.
360fn int_token(token: &str) -> Result<BigInt> {
361 if !token.is_empty() && token.chars().all(|c| c.is_ascii_digit()) {
362 // Leading zeros are fine — Python's int("007") is 7, and so is this.
363 if let Some(v) = BigInt::parse_bytes(token.as_bytes(), 10) {
364 return Ok(v);
365 }
366 }
367 Err(N2WError::Value(format!(
368 "invalid literal for int() with base 10: '{}'",
369 token
370 )))
371}
372
373/// CPython's `repr(float)` — what `str(number)` returns for a `float`.
374///
375/// `precision` is the shim's `abs(Decimal(repr(value)).as_tuple().exponent)`,
376/// i.e. the number of fractional digits `repr` emitted. Its whole purpose is
377/// to keep shortest-round-trip digit selection on the Python side, so the
378/// fixed-notation branch just asks for exactly that many digits:
379/// `{:.p$}` is correctly-rounded fixed formatting, and the nearest
380/// `p`-digit decimal to the double *is* `repr`. (No tie can split them: a tie
381/// would mean the double sits exactly halfway between two `p`-digit decimals,
382/// which forces `repr` to spend a `p+1`-th digit and contradicts `p`.)
383///
384/// # The one thing that must be reimplemented
385///
386/// `repr` switches to exponent notation, and `precision` cannot reveal when:
387/// `repr(1e-05)` is `'1e-05'` and `repr(0.00012)` is `'0.00012'`, and both
388/// report `precision == 5`. CPython decides in `format_float_short`:
389///
390/// ```text
391/// case 'r': /* convert to exponential format at 1e16. This is used for repr. */
392/// if (decpt <= -4 || decpt > 16)
393/// use_exp = 1;
394/// ```
395///
396/// where `decpt` positions the decimal point against the *shortest* digits
397/// (`value == 0.<digits> * 10**decpt`). Reading `decpt` back out would mean
398/// reimplementing the digit selection this function exists to avoid — but the
399/// test collapses to a comparison on the value itself:
400///
401/// * `decpt > 16` ⟺ `|v| >= 1e16`. A shortest decimal `D < 10**16` can only
402/// round to a double `>= 10**16` if that double *is* `1e16` (doubles are
403/// spaced 2 apart there, and `float("9999999999999999")` ties to even and
404/// lands on it) — and `1e16`'s own shortest form is the 1-digit `"1"` with
405/// `decpt == 17`, so `decpt <= 16` never survives.
406/// * `decpt <= -4` ⟺ `0 < |v| < 1e-4`. The double nearest `1e-4` sits just
407/// *above* it, so no `D >= 10**-4` rounds below the boundary and vice
408/// versa.
409/// * `v == 0.0` is neither: CPython's dtoa reports `decpt == 1` for zero, so
410/// `repr(0.0)` is the fixed `'0.0'` (and `repr(-0.0)` is `'-0.0'`, a sign
411/// `{:.1}` preserves and `is_negative()` would not).
412///
413/// Checked against CPython over 608,900 doubles — random bit patterns plus
414/// 300-step `nextafter` walks either side of `1e16`, `1e-4`, `1e-5`, `1e15`,
415/// `1e17`, `1e23`, `5e-324` and `f64::MAX` — with zero divergence.
416///
417/// The exponent branch then reuses Rust's `{:e}`, whose mantissa is already
418/// normalised to one leading digit exactly like CPython's; only the exponent
419/// spelling differs (`e16` vs `e+16`, `e-5` vs `e-05`). That branch *does*
420/// lean on Rust and CPython agreeing on the shortest digits, which the same
421/// 608,900-value run confirms. Every value reaching it raises `ValueError`
422/// regardless (see [`int_token`]) — the digits only pick which token the
423/// message quotes.
424fn python_float_repr(v: f64, precision: u32) -> String {
425 // repr(nan)/repr(inf) are lowercase in Python; Rust's Display renders NaN
426 // as "NaN". Neither is reachable through the shim (the Python-side
427 // precision computation does abs() on a Decimal exponent of 'F'/'n',
428 // which is a str and raises TypeError first), but `int()` would reject
429 // both tokens anyway, so spell them Python's way and let int_token raise.
430 if v.is_nan() {
431 return "nan".to_string();
432 }
433 if v.is_infinite() {
434 return if v < 0.0 { "-inf" } else { "inf" }.to_string();
435 }
436
437 let a = v.abs();
438 if a != 0.0 && (a >= 1e16 || a < 1e-4) {
439 // "-1.234e-5" -> "-1.234e-05"; "1e16" -> "1e+16".
440 let sci = format!("{:e}", v);
441 let (mantissa, exp) = sci
442 .split_once('e')
443 .expect("Rust's {:e} always emits an 'e' separator");
444 let exp: i32 = exp
445 .parse()
446 .expect("Rust's {:e} always emits a decimal exponent");
447 return format!(
448 "{}e{}{:02}",
449 mantissa,
450 if exp < 0 { '-' } else { '+' },
451 exp.abs()
452 );
453 }
454
455 format!("{:.*}", precision as usize, v)
456}
457
458/// CPython's `Decimal.__str__` — what `str(number)` returns for a `Decimal`.
459///
460/// A transcription of `_pydecimal.Decimal.__str__`, which works off the
461/// coefficient digits and the exponent rather than the value:
462///
463/// ```python
464/// leftdigits = self._exp + len(self._int)
465/// if self._exp <= 0 and leftdigits > -6:
466/// dotplace = leftdigits # no exponent required
467/// else:
468/// dotplace = 1 # scientific: 1 digit left of the point
469/// ...
470/// if leftdigits == dotplace: exp = ''
471/// else: exp = 'E%+d' % (leftdigits - dotplace)
472/// ```
473///
474/// `BigDecimal::as_bigint_and_exponent` hands back exactly the pair Python
475/// stores — `scale` is the negation of `_exp`, and the coefficient keeps its
476/// significant digits verbatim, trailing zeros included (`"1.10"` is 110×10⁻²
477/// on both sides, `"0.00"` is 0×10⁻²). That fidelity is the whole reason this
478/// is reconstructed rather than delegated: **`BigDecimal`'s own `Display` is
479/// not `str(Decimal)`.** It prints `"0.00"` as `"0"` (dropping a scale Python
480/// keeps, which would silently eat two fractional words) and `"1E+16"` as
481/// lowercase `"1e+16"` (Python emits uppercase `E`, and that string is quoted
482/// verbatim in the `ValueError` this value raises).
483///
484/// Note the thresholds differ from [`python_float_repr`]'s: `Decimal` goes
485/// scientific whenever `_exp > 0` or `leftdigits <= -6`, so `Decimal("1E+16")`
486/// stringifies as `'1E+16'` and raises where the numerically equal
487/// `Decimal(10**16)` stringifies as plain digits and converts. Reproducing
488/// `repr(float)`'s rule here instead would be wrong in both directions.
489///
490/// # Known gap: negative zero
491///
492/// `str(Decimal("-0.0"))` is `'-0.0'`, so BO emits its negword. `BigInt` has
493/// no signed zero, so `BigDecimal::from_str("-0.0")` arrives here as `+0`
494/// scale 1 and the sign is already gone — this returns `"0.0"` and the negword
495/// is dropped. The sign is lost at the boundary, before this function runs, so
496/// it cannot be recovered here. Flagged in the port report; not in the corpus.
497fn python_decimal_str(d: &BigDecimal) -> String {
498 let (coefficient, scale) = d.as_bigint_and_exponent();
499 // Python's `_exp`; `as_bigint_and_exponent` reports the negated form.
500 let exp = -scale;
501 let sign = if coefficient.is_negative() { "-" } else { "" };
502 // Python's `_int`: the unsigned coefficient digits. BigInt renders ASCII
503 // only, so the byte slicing below is char slicing.
504 let digits = coefficient.abs().to_string();
505
506 let leftdigits = exp + digits.len() as i64;
507 let dotplace = if exp <= 0 && leftdigits > -6 {
508 leftdigits
509 } else {
510 1
511 };
512
513 // Every repeat below is provably tiny, so none of this can blow up on a
514 // `Decimal("1E+1000000")`: the scientific branch pins dotplace to 1, and
515 // the plain branch has exp <= 0, hence dotplace == leftdigits <= len.
516 let (intpart, fracpart) = if dotplace <= 0 {
517 // 0.<zeros><digits> — at most 5 zeros, since leftdigits > -6.
518 (
519 "0".to_string(),
520 format!(".{}{}", "0".repeat(-dotplace as usize), digits),
521 )
522 } else if dotplace as usize >= digits.len() {
523 (
524 format!("{}{}", digits, "0".repeat(dotplace as usize - digits.len())),
525 String::new(),
526 )
527 } else {
528 (
529 digits[..dotplace as usize].to_string(),
530 format!(".{}", &digits[dotplace as usize..]),
531 )
532 };
533
534 let exponent = if leftdigits == dotplace {
535 String::new()
536 } else {
537 // Python's 'E%+d' — a mandatory sign and no zero padding, unlike
538 // repr(float)'s two-digit minimum.
539 let e = leftdigits - dotplace;
540 format!("E{}{}", if e < 0 { '-' } else { '+' }, e.abs())
541 };
542
543 format!("{}{}{}{}", sign, intpart, fracpart, exponent)
544}
545
546/// `str(number)` as `Num2Word_BO.to_cardinal` sees it.
547///
548/// The `FloatValue` split is exactly the `float`/`Decimal` split CPython makes
549/// when choosing which `__str__`/`__repr__` to run, so it maps straight onto
550/// the two reconstructions.
551fn python_str(v: &FloatValue) -> String {
552 match v {
553 FloatValue::Float { value, precision } => python_float_repr(*value, *precision),
554 FloatValue::Decimal { value, .. } => python_decimal_str(value),
555 }
556}
557
558/// Port of `Num2Word_BO._split_currency`, applied to `abs(val)`.
559///
560/// ```text
561/// def _split_currency(self, n):
562/// parts = str(n).split(".")
563/// left = int(parts[0]) if parts[0] else 0
564/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
565/// return left, right
566/// ```
567///
568/// This is a *string* operation on `str(n)`, so the port has to reproduce what
569/// `str()` would have produced rather than just doing decimal arithmetic:
570///
571/// * **`CurrencyValue::Int`** — `str(int)` is plain digits with no "." and no
572/// exponent at any magnitude, so `parts` has one element: `left` is the
573/// integer, `right` is 0. An int therefore never shows cents, which is the
574/// `isinstance(val, int)` distinction the boundary preserves.
575///
576/// * **`CurrencyValue::Decimal`** — the `BigDecimal` was parsed from the exact
577/// `str(value)` the Python side produced, so its digits *are* the characters
578/// Python split on. For any plain-decimal `str()`, Python's
579/// `int(parts[1][:2].ljust(2, "0"))` — take the first two fractional digits,
580/// right-pad with zeros — is exactly `floor(frac * 100)`, which is what the
581/// truncating arithmetic below computes:
582///
583/// | `str(n)` | `parts[1][:2].ljust(2,"0")` | `right` | `floor(frac*100)` |
584/// |---|---|---|---|
585/// | `"12.34"` | `"34"` | 34 | 34 |
586/// | `"0.5"` | `"50"` | 50 | 50 |
587/// | `"1.0"` | `"00"` | 0 | 0 |
588/// | `"0.001"` | `"00"` | 0 | 0 |
589/// | `"0.567"` | `"56"` | 56 | 56 |
590///
591/// The `ljust` and the `[:2]` truncation are both subsumed: `BigDecimal`
592/// carries the trailing zeros of `"1.0"` in its scale, and `with_scale(0)`
593/// truncates rather than rounds (`99.99 -> 99`), matching `[:2]` discarding
594/// the third digit instead of rounding on it.
595///
596/// # The exponent-notation `ValueError`
597///
598/// `str()` switches to exponent notation for a float outside roughly
599/// `1e-4 <= |v| < 1e16`, and `int()` chokes on the resulting token:
600/// `str(1e16)` is `"1e+16"`, `parts` is `["1e+16"]`, and `int("1e+16")` raises
601/// **`ValueError`**. (`str(1.5e16)` is `"1.5e+16"`, where `parts[1][:2]` is
602/// `"5e"` and `int("5e")` raises `ValueError` too — same exception, different
603/// token.) So the whole function raises for large floats rather than returning
604/// anything.
605///
606/// The check below keys off the *representation*, which is the same thing
607/// Python's `str().split(".")` does, so it stays faithful for `Decimal` inputs
608/// too (the shim accepts those alongside floats): `Decimal("1E+16")` stringifies
609/// with an exponent and raises, while `Decimal(10**16)` stringifies as plain
610/// digits and succeeds — and the two parse to different scales here (-16 vs 0),
611/// so both are handled correctly.
612///
613/// A **positive** exponent is therefore recoverable: plain decimal notation can
614/// never yield a negative `BigDecimal` scale, so `scale < 0` proves `str()`
615/// emitted an exponent, and the `ValueError` is raised here. Verified against
616/// CPython: the message matches `int("1e+16")`'s byte for byte.
617///
618/// A **negative** exponent is not recoverable, and this is the one known gap.
619/// `str(1e-05)` is `"1e-05"` (CPython goes scientific below `1e-4`) and raises,
620/// but it parses to the very same `(1, scale=5)` as `Decimal("0.00001")`, which
621/// stringifies as `"0.00001"` and does *not* raise. The two are indistinguishable
622/// once across the boundary, so a float `< 1e-4` silently returns `(0, 0)` here
623/// where Python raises `ValueError`. Discriminating on the value instead
624/// (`|v| < 1e-4`) would fix the float and break the `Decimal`, so the
625/// representation-faithful rule is kept and the gap is flagged in the report
626/// rather than papered over. Reproducing it properly needs `repr(float)`, which
627/// `currency.rs` deliberately keeps on the Python side.
628fn split_currency(val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
629 match val {
630 CurrencyValue::Int(v) => Ok((v.abs(), BigInt::zero())),
631 CurrencyValue::Decimal { value: d, .. } => {
632 let d = d.abs();
633 if d.as_bigint_and_exponent().1 < 0 {
634 // str(n) used exponent notation; int() on that token raises.
635 return Err(N2WError::Value(format!(
636 "invalid literal for int() with base 10: '{}'",
637 d
638 )));
639 }
640 // int(parts[0]) — `d` is non-negative, so with_scale(0) truncating
641 // toward zero is the integer part.
642 let left = d.with_scale(0);
643 // int(parts[1][:2].ljust(2, "0")) == floor(frac * 100).
644 let frac = &d - &left;
645 let right = (frac * BigDecimal::from(100)).with_scale(0);
646 Ok((
647 left.as_bigint_and_exponent().0,
648 right.as_bigint_and_exponent().0,
649 ))
650 }
651 }
652}
653
654pub struct LangBo {
655 /// `Num2Word_BO.CURRENCY_FORMS`, built once in [`LangBo::new`].
656 ///
657 /// The binding holds each `LangBo` in a `OnceLock`, so this table is
658 /// constructed exactly once per process rather than per call.
659 currency_forms: HashMap<&'static str, CurrencyForms>,
660}
661
662impl Default for LangBo {
663 fn default() -> Self {
664 Self::new()
665 }
666}
667
668impl LangBo {
669 pub fn new() -> Self {
670 // Num2Word_BO.CURRENCY_FORMS — three codes, each with two identical
671 // forms per side. Tibetan does not inflect for number, so the singular
672 // and plural slots carry the same word; the arity is kept at Python's
673 // 2 because `to_cheque` reads `cr1[-1]`.
674 let mut currency_forms = HashMap::new();
675 currency_forms.insert(
676 "CNY",
677 CurrencyForms::new(&["སྒོར་", "སྒོར་"], &["ཕན་", "ཕན་"]),
678 );
679 currency_forms.insert(
680 "USD",
681 CurrencyForms::new(&["ཌོ་ལར་", "ཌོ་ལར་"], &["སེན་", "སེན་"]),
682 );
683 currency_forms.insert(
684 "EUR",
685 CurrencyForms::new(&["ཡུ་རོ་", "ཡུ་རོ་"], &["སེན་", "སེན་"]),
686 );
687 LangBo { currency_forms }
688 }
689}
690
691impl Lang for LangBo {
692
693 fn cardinal_float_entry(
694 &self,
695 value: &crate::floatpath::FloatValue,
696 precision_override: Option<u32>,
697 ) -> crate::base::Result<String> {
698 // Python's to_cardinal routes every float/Decimal through this
699 // language's own decimal grammar — 5.0 keeps its ".0" tail
700 // ("comma nulla"), unlike Base's whole-value integer route.
701 self.to_cardinal_float(value, precision_override)
702 }
703 /// This language's own `to_currency(currency=...)` default,
704 /// read from the live Python signature. Only 44 of 156 use EUR.
705 fn default_currency(&self) -> &str {
706 "CNY"
707 }
708
709 /// This language's own `to_currency(separator=...)` default,
710 /// read from the live Python signature. Base's is ",", but only
711 /// 36 of 149 languages actually use it — most default to " " or a
712 /// conjunction, so inheriting Base's comma silently corrupts them.
713 fn default_separator(&self) -> &str {
714 " དང་ "
715 }
716
717 fn negword(&self) -> &str {
718 NEGWORD
719 }
720
721 fn pointword(&self) -> &str {
722 "ཚེག་"
723 }
724
725 /// Port of `Num2Word_BO.to_cardinal`, integer path only.
726 ///
727 /// Python stringifies the input, strips it, peels a leading "-" into
728 /// `ret = self.negword`, then looks for "."; `str(int)` never contains
729 /// one, so integers always take the `else` branch:
730 /// `return ret + self._int_to_word(int(n))`. The float branch of the same
731 /// method is [`LangBo::to_cardinal_float`].
732 ///
733 /// The negword is concatenated with **no separator** — the space that
734 /// separates it from the number is the literal's own trailing space.
735 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
736 let (prefix, magnitude) = if value.is_negative() {
737 (NEGWORD, value.abs())
738 } else {
739 ("", value.clone())
740 };
741 Ok(format!("{}{}", prefix, int_to_word(&magnitude)))
742 }
743
744 /// Port of `Num2Word_BO.to_ordinal`: `to_cardinal(number) + "པ་"`.
745 ///
746 /// No `verify_ordinal` guard — negatives and zero pass straight through.
747 /// See bug note 1 in the module docs.
748 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
749 let cardinal = self.to_cardinal(value)?;
750 Ok(format!("{}{}", cardinal, ORDINAL_SUFFIX))
751 }
752
753 /// Port of `Num2Word_BO.to_ordinal_num`: `str(number) + "པ"`.
754 ///
755 /// Overrides the base (which returns the value unchanged). The suffix
756 /// carries no trailing tsheg, unlike `to_ordinal`'s — that asymmetry is
757 /// in the Python source and is preserved. A negative keeps its ASCII
758 /// minus: `to_ordinal_num(-1)` == "-1པ".
759 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
760 Ok(format!("{}{}", value, ORDINAL_NUM_SUFFIX))
761 }
762
763 /// Port of `Num2Word_BO.to_cardinal`, non-integer path.
764 ///
765 /// ```text
766 /// def to_cardinal(self, number):
767 /// n = str(number).strip()
768 /// if n.startswith("-"):
769 /// n = n[1:]
770 /// ret = self.negword
771 /// else:
772 /// ret = ""
773 /// if "." in n:
774 /// left, right = n.split(".", 1)
775 /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
776 /// ret += " ".join(self._int_to_word(int(d)) for d in right)
777 /// return ret
778 /// else:
779 /// return ret + self._int_to_word(int(n))
780 /// ```
781 ///
782 /// The same method as [`LangBo::to_cardinal`]; the trait splits what
783 /// Python keeps as one function, so this arm carries the `"."` branch and
784 /// the `else` that a scientific-notation `str()` still falls into. Nothing
785 /// from `Num2Word_Base.to_cardinal_float` runs — see the module docs for
786 /// why `float2tuple`, its f64 artefacts and its banker's rounding are all
787 /// irrelevant here.
788 ///
789 /// Points worth naming:
790 ///
791 /// 1. **`precision_override` is ignored, deliberately.** `Num2Word_BO` does
792 /// inherit a `precision` attribute (2), and the dispatcher does assign
793 /// to it for `precision=`, but nothing on this path ever reads it —
794 /// the digit count comes from `str(number)`. Verified against the live
795 /// interpreter: `num2words(2.675, lang="bo", precision=1)` and
796 /// `precision=5` both return the unmodified three-digit form. Honouring
797 /// the override here would invent behaviour Python does not have.
798 /// 2. **The sign comes from the string, not the value.** Python tests
799 /// `n.startswith("-")`, which is true for `-0.0` (`repr` keeps the sign
800 /// bit) where `FloatValue::is_negative`'s `value < 0.0` is false. So
801 /// `-0.0` correctly keeps its negword.
802 /// 3. **`negword` is used raw, not stripped** — `ret = self.negword`,
803 /// where `Num2Word_Base.to_cardinal_float` would insert
804 /// `negword.strip()` as a separate word. Both land on "མེད་ཆ་ " here,
805 /// the literal already ending in exactly one space, but that is a
806 /// coincidence of the literal rather than shared logic.
807 /// 4. **One word per *character*, not per digit position.** The fractional
808 /// part is iterated as characters of `str()`, so it is as long as the
809 /// string says: `Decimal("1.10")` keeps its trailing zero ("one point
810 /// one zero") while the float `1.10` does not ("one point one").
811 /// 5. **`left` is converted before `right` is inspected**, matching the
812 /// statement order, so a bad `left` raises before any fractional
813 /// character is looked at. `join` fully consumes the generator, so
814 /// within `right` the *first* bad character wins.
815 fn to_cardinal_float(
816 &self,
817 value: &FloatValue,
818 _precision_override: Option<u32>,
819 ) -> Result<String> {
820 // n = str(number).strip() — str() of a float or a Decimal never has
821 // surrounding whitespace, so the strip is a no-op Python keeps for
822 // the str-input path that never reaches this hook.
823 let s = python_str(value);
824 let n = s.trim();
825
826 // if n.startswith("-"): n = n[1:]; ret = self.negword
827 let (n, mut ret) = match n.strip_prefix('-') {
828 Some(rest) => (rest, NEGWORD.to_string()),
829 None => (n, String::new()),
830 };
831
832 // `split(".", 1)` — the first "." only.
833 let Some((left, right)) = n.split_once('.') else {
834 // else: return ret + self._int_to_word(int(n))
835 ret.push_str(&int_to_word(&int_token(n)?));
836 return Ok(ret);
837 };
838
839 // ret += self._int_to_word(int(left)) + " " + self.pointword + " "
840 ret.push_str(&int_to_word(&int_token(left)?));
841 ret.push(' ');
842 // POINTWORD is `setup()`'s literal, the same string `pointword()`
843 // reports; Python reads the attribute this const stands for.
844 ret.push_str(POINTWORD);
845 ret.push(' ');
846
847 // ret += " ".join(self._int_to_word(int(d)) for d in right)
848 let mut fraction = Vec::new();
849 for d in right.chars() {
850 let mut buf = [0u8; 4];
851 fraction.push(int_to_word(&int_token(d.encode_utf8(&mut buf))?));
852 }
853 ret.push_str(&fraction.join(" "));
854 Ok(ret)
855 }
856
857 /// Port of `Num2Word_BO.to_year`: `"སྤྱི་ལོ་" + to_cardinal(val)`.
858 ///
859 /// Python's `longval=True` parameter is accepted and then ignored, so the
860 /// trait's single-argument signature loses nothing. Negative years put
861 /// the era marker *before* the negword — see bug note 2 in the module
862 /// docs.
863 fn to_year(&self, value: &BigInt) -> Result<String> {
864 let cardinal = self.to_cardinal(value)?;
865 Ok(format!("{}{}", YEAR_PREFIX, cardinal))
866 }
867
868 // ---- float / Decimal entry routing --------------------------------
869 //
870 // `to_ordinal` / `to_ordinal_num` / `to_year` have no type guard in
871 // Python, so a float or Decimal flows through them exactly as an int
872 // does: the suffix / era prefix wraps the *full* decimal phrase (or, for
873 // `to_ordinal_num`, the raw `str(number)`).
874
875 /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "པ་"` — the
876 /// full decimal phrase plus the suffix; the exponential-form ValueError
877 /// from the cardinal propagates unchanged.
878 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
879 Ok(format!(
880 "{}{}",
881 self.cardinal_float_entry(value, None)?,
882 ORDINAL_SUFFIX
883 ))
884 }
885
886 /// `to_ordinal_num(float/Decimal)`: `str(number) + "པ"` — no `int()`, so
887 /// it succeeds where the other modes raise ("1e+16པ") and "-0.0" keeps
888 /// its textual minus. `repr_str` is Python's `str(number)`.
889 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
890 Ok(format!("{}{}", repr_str, ORDINAL_NUM_SUFFIX))
891 }
892
893 /// `to_year(float/Decimal)`: `"སྤྱི་ལོ་" + self.to_cardinal(val)` — era
894 /// marker before the negword, ValueErrors included.
895 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
896 Ok(format!(
897 "{}{}",
898 YEAR_PREFIX,
899 self.cardinal_float_entry(value, None)?
900 ))
901 }
902
903 /// `converter.str_to_number` — Base's `Decimal(value)`, which BO does not
904 /// override. The `Inf` interception reproduces what happens *next* on the
905 /// pinned path: `to_cardinal(Decimal("Infinity"))` reads `str(number)` ==
906 /// "Infinity" (the "-Infinity" case strips its sign textually first),
907 /// finds no ".", and dies in `int("Infinity")` with ValueError; the
908 /// binding's shared Inf sentinel would otherwise raise OverflowError.
909 /// (NaN needs no interception: the binding's ValueError already matches
910 /// `int("NaN")`'s type.)
911 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
912 match python_decimal_parse(s)? {
913 ParsedNumber::Inf { .. } => Err(N2WError::Value(
914 "invalid literal for int() with base 10: 'Infinity'".into(),
915 )),
916 other => Ok(other),
917 }
918 }
919
920 // ---- currency ----------------------------------------------------
921
922 /// For `to_cheque`'s `'X' not implemented for 'Num2Word_BO'` message.
923 fn lang_name(&self) -> &str {
924 "Num2Word_BO"
925 }
926
927 /// `Num2Word_BO.CURRENCY_FORMS[code]`, with `None` for a missing code.
928 ///
929 /// Only the inherited `to_cheque` reads currency forms through this hook,
930 /// and it wants the strict `CURRENCY_FORMS[currency]` lookup that raises
931 /// `NotImplementedError` on a `KeyError`. BO's own `to_currency` uses the
932 /// *lenient* `.get(currency, CURRENCY_FORMS["CNY"])` instead and so reads
933 /// the field directly rather than going through here — see
934 /// [`LangBo::to_currency`].
935 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
936 self.currency_forms.get(code)
937 }
938
939 /// Port of `Num2Word_BO.to_currency`.
940 ///
941 /// ```text
942 /// def to_currency(self, val, currency="CNY", cents=True,
943 /// separator=" དང་ ", adjective=False):
944 /// try:
945 /// left, right, is_negative = self.parse_currency(val)
946 /// except AttributeError:
947 /// is_negative = False
948 /// if val < 0:
949 /// is_negative = True
950 /// val = abs(val)
951 /// left, right = self._split_currency(val)
952 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["CNY"])
953 /// left_str = self._int_to_word(int(left))
954 /// cents_str = self._int_to_word(int(right)) if cents and right else ""
955 /// result = left_str + " " + cr1[0]
956 /// if cents_str:
957 /// result += separator + cents_str + " " + cr2[0]
958 /// return self.negword + result if is_negative else result
959 /// ```
960 ///
961 /// The `try` is dead — `parse_currency` exists nowhere, so the
962 /// `AttributeError` branch always wins (see the module docs). The rest is
963 /// a direct transcription. Points worth naming, all corpus-confirmed:
964 ///
965 /// 1. **This override never raises `NotImplementedError`.** The `.get`
966 /// fallback means an unknown code renders in CNY words, in flat
967 /// contradiction to the inherited `to_cheque`. `currency:GBP`,
968 /// `currency:JPY`, `currency:KWD`, `currency:BHD`, `currency:INR` and
969 /// `currency:CHF` all come out as CNY in the corpus.
970 /// 2. **A float with zero cents drops the cents segment.** `cents_str` is
971 /// gated on `right` being *truthy*, so `1.0` — which `_split_currency`
972 /// reduces to `right = 0` — renders "གཅིག་ ཡུ་རོ་", with no cents at all.
973 /// `Num2Word_Base.to_currency` would emit "... and zero cents" here; BO
974 /// does not, and the int/float distinction is invisible in its output.
975 /// Both `1` and `1.0` give "གཅིག་ ཡུ་རོ་". The distinction is still
976 /// threaded through faithfully (see [`split_currency`]) because it is
977 /// load-bearing everywhere else and only *coincidentally* collapses here.
978 /// 3. **`cents=False` omits the cents instead of making them terse.** The
979 /// base class switches to `_cents_terse` (digits); BO's `cents and right`
980 /// guard just suppresses the whole segment, so `_cents_terse` is
981 /// unreachable and the trait default is left alone.
982 /// 4. **`adjective` is accepted and completely ignored.** BO declares the
983 /// parameter, never reads it, and inherits an empty `CURRENCY_ADJECTIVES`
984 /// anyway — hence `_adjective` below and no `currency_adjective` override.
985 /// 5. **`negword` is used raw, not stripped.** `self.negword + result`,
986 /// where the base would do `"%s " % self.negword.strip()`. Both give
987 /// "མེད་ཆ་ " because the literal already ends in exactly one space.
988 fn to_currency(
989 &self,
990 val: &CurrencyValue,
991 currency: &str,
992 cents: bool,
993 separator: Option<&str>,
994 _adjective: bool,
995 ) -> Result<String> {
996 // Trait now hands us None when the caller omitted separator=;
997 // resolve it to this language's own default before the ported body.
998 let separator = separator.unwrap_or(self.default_separator());
999 // Restore BO's own `separator=" དང་ "` default; see SEPARATOR_UNSET.
1000 let separator = if separator == SEPARATOR_UNSET {
1001 SEPARATOR_DEFAULT
1002 } else {
1003 separator
1004 };
1005
1006 // The `except AttributeError` branch: capture the sign, then split
1007 // abs(val). `split_currency` takes the absolute value itself.
1008 let is_negative = val.is_negative();
1009 let (left, right) = split_currency(val)?;
1010
1011 // CURRENCY_FORMS.get(currency, CURRENCY_FORMS["CNY"]) — lenient, unlike
1012 // the strict lookup `to_cheque` performs through `currency_forms()`.
1013 let forms = self
1014 .currency_forms
1015 .get(currency)
1016 .or_else(|| self.currency_forms.get(FALLBACK_CURRENCY))
1017 .expect("CURRENCY_FORMS always carries the CNY fallback entry");
1018
1019 let left_str = int_to_word(&left);
1020 // `cents and right` — a zero `right` is falsy, so no cents segment.
1021 // `_int_to_word` is never "" for a non-zero input, so testing the
1022 // Python string for truthiness is the same as testing this condition.
1023 let show_cents = cents && !right.is_zero();
1024
1025 let mut result = format!("{} {}", left_str, forms.unit[0]);
1026 if show_cents {
1027 result.push_str(separator);
1028 result.push_str(&int_to_word(&right));
1029 result.push(' ');
1030 result.push_str(&forms.subunit[0]);
1031 }
1032
1033 Ok(if is_negative {
1034 format!("{}{}", NEGWORD, result)
1035 } else {
1036 result
1037 })
1038 }
1039}