num2words2_core/lang_yi.rs
1//! Port of `lang_YI.py` (Yiddish).
2//!
3//! Shape: **self-contained**. `Num2Word_YI` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
6//! outright and drives a recursive `_int_to_word`. Consequently
7//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
8//! **no overflow check at all** — `_int_to_word` falls back to `str(number)`
9//! above 10**9 instead of raising (see bug 3 below).
10//!
11//! Nothing in the four in-scope modes can raise: `to_cardinal` strips the sign
12//! from the *string* before `int()`, every table index is range-guarded by the
13//! `< 10` / `< 100` / `< 1000` cascade, and the `else` arm swallows everything
14//! else. So this port returns `Ok(..)` unconditionally and never constructs an
15//! `N2WError`.
16//!
17//! Inherited from `Num2Word_Base` (YI overrides `to_year` but to the same
18//! effect, and does not touch `verify_ordinal`):
19//! * `to_year(val, longval=True) -> self.to_cardinal(val)` — the `longval`
20//! parameter is accepted and ignored, so years are plain cardinals with no
21//! century splitting: `to_year(1900)` == `to_cardinal(1900)` ==
22//! "איינס טויזנט נײַן הונדערט", *not* "nineteen hundred".
23//! * `verify_ordinal` is **never called** — `to_ordinal` accepts negatives
24//! and simply suffixes the (sign-bearing) cardinal, so `to_ordinal(-1)` ==
25//! "minus איינס-טער" rather than raising TypeError.
26//!
27//! # Faithfully reproduced Python bugs
28//!
29//! This is a port, not a rewrite. Everything below looks wrong and is exactly
30//! what Python emits — all verified against the frozen corpus:
31//!
32//! 1. **Yiddish has no teens and no "un-" tens compounding.** `_int_to_word`
33//! handles 10..99 as `tens[n // 10] + " " + ones[n % 10]`, unconditionally.
34//! There is no `low_numwords`-style 11..19 table, so 11 renders as
35//! "צען איינס" (literally "ten one") and 12 as "צען צוויי". Real Yiddish
36//! would use elf/tsvelf and put the unit first ("ein un tsvantsik"); the
37//! library does neither. 21 == "צוואַנציק איינס".
38//! 2. **Hundreds/thousands/millions take no multiplier elision.** The
39//! `< 1000` arm is `self.ones[hundreds_val] + " " + self.hundred` with no
40//! `if hundreds_val > 1` guard, so 100 == "איינס הונדערט" ("one hundred"),
41//! and by recursion 1000 == "איינס טויזנט" and 10**6 == "איינס מיליאָן".
42//! 3. **Above 10**9 the converter gives up and returns the digits.** The final
43//! `else` arm of `_int_to_word` is literally `return str(number)`, so
44//! `to_cardinal(10**9)` == "1000000000" — a *digit string*, not words. It
45//! does not raise OverflowError; there is no `MAXVAL`. `to_ordinal(10**9)`
46//! therefore yields "1000000000-טער". Modelled by [`LangYi::int_to_word`].
47//! 4. **`ones[0]` is `""`, so zero routes through a dead ternary to the
48//! English word.** `_int_to_word` opens with
49//! `return self.ones[0] if self.ones[0] else "zero"` — `""` is falsy, so the
50//! branch is unreachable and zero is always the *English* "zero", never a
51//! Yiddish word. Hence `to_cardinal(0)` == "zero" and `to_ordinal(0)` ==
52//! "zero-טער". See [`ZERO`].
53//! 5. **`negword` is the English "minus "** (not "מינוס"), and `to_cardinal`
54//! prepends it raw rather than via `parse_minus`, so the output mixes
55//! scripts: `to_cardinal(-100)` == "minus איינס הונדערט".
56//! 6. **`to_ordinal` is cardinal + a hyphenated suffix, with no agreement and
57//! no last-word inflection.** `return cardinal + "-טער"` — the suffix is
58//! glued to whatever came out, including the digit-string fallback (bug 3)
59//! and the English "zero" (bug 4).
60//!
61//! # Dead code in the source, reproduced anyway
62//!
63//! `_int_to_word`'s `if number < 0` arm is unreachable from all four integer
64//! modes: `to_cardinal` slices the "-" off the *string* (`n = n[1:]`) before
65//! `int(n)`, so `_int_to_word` only ever sees a non-negative value. It is
66//! unreachable from `to_currency` too, which takes `abs(val)` before splitting.
67//! It is kept in [`LangYi::int_to_word`] for fidelity — with the same ordering
68//! quirk that `number == 0` is tested *before* `number < 0`.
69//!
70//! # Currency
71//!
72//! `Num2Word_YI` overrides `to_currency` **wholesale** and shares almost
73//! nothing with `Num2Word_Base`'s version. It does not call `pluralize`, does
74//! not consult `CURRENCY_PRECISION`, does not use `parse_currency_parts`, and
75//! ignores its own `adjective` parameter. So `currency::default_to_currency` is
76//! bypassed entirely and the divisor is hard-wired to 100 for every code.
77//!
78//! `to_cheque` is *not* overridden, so `Num2Word_Base.to_cheque` runs — which
79//! means the two entry points disagree about unknown currency codes:
80//!
81//! | code | `to_currency` | `to_cheque` |
82//! |------------|--------------------------|----------------------|
83//! | EUR / USD | its own forms | its own forms |
84//! | anything else | silently uses **EUR** | NotImplementedError |
85//!
86//! That asymmetry is bug 7 below and is exactly what the corpus records.
87//!
88//! ## More faithfully reproduced Python bugs
89//!
90//! 7. **`to_currency` never raises for an unknown code.** The lookup is
91//! `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
92//! — a `.get` with a *default*, not a `[]`. The default is the first value in
93//! a dict written `{"EUR": ..., "USD": ...}`, so every unrecognised code
94//! (GBP, JPY, KWD, INR, ...) renders in euros: `to_currency(1, "GBP")` ==
95//! "איינס אייראָ". Only `to_cheque`, inherited from the base, raises
96//! `NotImplementedError`. See [`FALLBACK_CODE`].
97//! 8. **Cents are truncated to two digits and right-padded, never rounded.**
98//! `int(parts[1][:2].ljust(2, "0"))` slices the *decimal string*: `0.5`
99//! means **50** cents (not 5), `1.239` gives 23 cents (not 24), and `1.005`
100//! gives 0. Modelled arithmetically as `floor(frac * 100)`.
101//! 9. **A zero cents value suppresses the whole segment**, because the guard is
102//! the truthiness test `if cents and right:`. So the float `1.0` prints
103//! "איינס אייראָ" with no cents — the `isinstance(val, int)` split that
104//! `Num2Word_Base.to_currency` relies on is absent here, and `1` and `1.0`
105//! coincidentally agree. `1.005` loses its cents the same way (bug 8).
106//! 10. **Currency mixes scripts and pluralizes in English.** USD carries the
107//! ASCII `("dollar", "dollars")`/`("cent", "cents")` rather than Yiddish, so
108//! `to_currency(2, "USD")` == "צוויי dollars", and a negative prepends the
109//! English `negword`: "minus צען צוויי dollars דרײַסיק פיר cents".
110//! 11. **EUR's singular and plural are the same string** (`("אייראָ", "אייראָ")`,
111//! `("צענט", "צענט")`), so the `left != 1` test is a no-op for EUR — and for
112//! every code that falls back to it (bug 7).
113//! 12. **`to_currency(0.0..1)` says "zero".** `left == 0` routes through
114//! `_int_to_word(0)`, which is the English "zero" (bug 4): `to_currency(0.01)`
115//! == "zero אייראָ איינס צענט".
116//!
117//! ## Unreachable from Rust: the sci-notation `ValueError`
118//!
119//! `to_currency` does `int(str(abs(val)).split(".")[0])`. Python's `repr` of a
120//! float switches to exponent form at `1e16` and below `1e-4`, and `int()`
121//! rejects that spelling — so `to_currency(1e20)` raises **ValueError**
122//! (`invalid literal for int() with base 10: '1e+20'`), as does `1e-5`. The
123//! shim hands this core `str(value)` already parsed into a `BigDecimal`, so the
124//! "1e+20" *spelling* — the sole cause of the crash — is gone before the port
125//! can see it, and these inputs render normally here instead of raising. No
126//! corpus row covers it; see the reported concerns.
127//!
128//! # The float / Decimal cardinal path
129//!
130//! `Num2Word_YI` does **not** define `to_cardinal_float`; it overrides
131//! `to_cardinal` and handles non-integers *inline*, working entirely on the
132//! *string* `str(number)`:
133//!
134//! ```python
135//! n = str(number).strip()
136//! if n.startswith("-"):
137//! n = n[1:]
138//! ret = self.negword
139//! else:
140//! ret = ""
141//! if "." in n:
142//! left, right = n.split(".", 1)
143//! ret += self._int_to_word(int(left)) + " " + self.pointword + " "
144//! for digit in right:
145//! ret += self._int_to_word(int(digit)) + " "
146//! return ret.strip()
147//! else:
148//! return (ret + self._int_to_word(int(n))).strip()
149//! ```
150//!
151//! So the fraction is the literal digits of the repr, one word per character —
152//! no `float2tuple`, no `10**precision`, no `< 0.01` rescue, no rounding and no
153//! padding. The port therefore reproduces `str()` exactly ([`py_str_f64`] /
154//! [`py_str_decimal`]) and runs the same string body ([`LangYi::cardinal_from_repr`]).
155//! Because the base float2tuple is *not* on this path, [`to_cardinal_float`]'s
156//! `precision_override` (the `precision=` kwarg) is **inert** — `to_cardinal`
157//! has no such parameter and never reads `self.precision`, verified against the
158//! live interpreter.
159//!
160//! [`to_cardinal_float`]: LangYi
161//!
162//! Three consequences, all matching the live interpreter and reproduced here:
163//!
164//! * **The f64 artefacts come from `repr`, not arithmetic.** `2.675` prints
165//! "זעקס זיבן פינף" (6 7 5) because `str(2.675) == "2.675"` (shortest
166//! round-trip), and `1.005` prints "zero zero פינף" because
167//! `str(1.005) == "1.005"`. There is no `674.9999…`→`675` heuristic here; the
168//! repr already carries the intended digits, so `py_str_f64` must be a
169//! byte-exact `repr` — including CPython's tie-to-**even** last digit, which
170//! Rust's shortest `{:e}` alone gets wrong (see [`shortest_repr_digits`]).
171//! * **A Decimal keeps every written digit.** `str(Decimal("1.10")) == "1.10"`,
172//! so the trailing "zero" appears — something the float `1.1` could never
173//! express (`str(1.1) == "1.1"`). Handled by [`py_str_decimal`], not
174//! `BigDecimal`'s own `Display`, which normalises differently.
175//! * **An exponent-form repr reaches `int()` and raises `ValueError`.** Unlike
176//! the `to_currency` case above — where the shim pre-parses away the spelling
177//! — the float path carries the raw `f64`, so `py_str_f64` reconstructs the
178//! real repr and the crash *is* reproducible: `to_cardinal_float(1e16)` and
179//! `(1e-5)` raise `ValueError: invalid literal for int() with base 10:
180//! '1e+16'` / `'1e-05'`, and `1.5e20` (which keeps a ".") dies on the single
181//! char `'e'` after already emitting the `5`. See bug 13 below.
182//!
183//! ## One more faithfully reproduced Python bug
184//!
185//! 13. **`_int_to_word(int(left))` inherits the `>= 10**9` digit-string
186//! fallback (bug 3).** The integer part of a large value comes back as bare
187//! digits: `to_cardinal_float(Decimal("98746251323029.99"))` ==
188//! "98746251323029 point נײַן נײַן", not words.
189
190use crate::base::{Lang, N2WError, Result};
191use crate::currency::{CurrencyForms, CurrencyValue};
192use crate::floatpath::FloatValue;
193use crate::strnum::{python_decimal_parse, ParsedNumber};
194use bigdecimal::BigDecimal;
195use num_bigint::BigInt;
196use num_traits::{One, Signed, ToPrimitive, Zero};
197use std::collections::HashMap;
198use std::str::FromStr;
199
200/// `_int_to_word(0)`. Python computes `self.ones[0] if self.ones[0] else "zero"`
201/// and `self.ones[0]` is `""` — falsy — so this English literal is the only
202/// reachable result. See module bug 4.
203const ZERO: &str = "zero";
204
205/// `self.negword`. English, with a trailing space, and used verbatim (not via
206/// `parse_minus`, which would `.strip()` it). See module bug 5.
207const NEGWORD: &str = "minus ";
208
209/// `self.ones`; index 0 is `""` in Python and is never reached as a word —
210/// the `number == 0` guard fires first. See module bug 4.
211const ONES: [&str; 10] = [
212 "",
213 "איינס",
214 "צוויי",
215 "דרײַ",
216 "פיר",
217 "פינף",
218 "זעקס",
219 "זיבן",
220 "אַכט",
221 "נײַן",
222];
223
224/// `self.tens`; index 0 is `""` and unreachable (`number < 10` handles it).
225/// Index 1 is "צען" (ten) — Yiddish here has no separate teens table, so this
226/// entry doubles as the tens-digit word for 10..19. See module bug 1.
227const TENS: [&str; 10] = [
228 "",
229 "צען",
230 "צוואַנציק",
231 "דרײַסיק",
232 "פערציק",
233 "פופציק",
234 "זעכציק",
235 "זיבעציק",
236 "אַכציק",
237 "נײַנציק",
238];
239
240/// `self.hundred`.
241const HUNDRED: &str = "הונדערט";
242/// `self.thousand`.
243const THOUSAND: &str = "טויזנט";
244/// `self.million`.
245const MILLION: &str = "מיליאָן";
246
247/// `to_ordinal`'s suffix, glued on with a literal hyphen. See module bug 6.
248const ORDINAL_SUFFIX: &str = "-טער";
249
250/// The ceiling of `_int_to_word`'s word-producing cascade. At or above this,
251/// Python returns `str(number)`. See module bug 3.
252const FALLBACK_LIMIT: u64 = 1_000_000_000;
253
254/// `self.__class__.__name__`, for the NotImplementedError `to_cheque` raises.
255const LANG_NAME: &str = "Num2Word_YI";
256
257/// `CURRENCY_FORMS["EUR"]`'s unit word. Python stores it in *both* the singular
258/// and the plural slot, so `left != 1` cannot change the output. See bug 11.
259const EURO: &str = "אייראָ";
260/// `CURRENCY_FORMS["EUR"]`'s subunit word — likewise identical in both slots.
261const EURO_CENT: &str = "צענט";
262
263/// The code that `list(self.CURRENCY_FORMS.values())[0]` resolves to.
264///
265/// `CURRENCY_FORMS` is the dict literal `{"EUR": ..., "USD": ...}` and Python
266/// dicts preserve insertion order, so the first value is always EUR's. That
267/// value is `to_currency`'s `.get(currency, <default>)` fallback, which is why
268/// an unknown code renders in euros rather than raising. See module bug 7.
269const FALLBACK_CODE: &str = "EUR";
270
271/// `Num2Word_YI.to_currency`'s declared default `separator=" "`, which
272/// *overrides* `Num2Word_Base.to_currency`'s `separator=","`.
273///
274/// Neither caller into this core can express "the caller omitted `separator`":
275/// `__init__.py` sends `kwargs.get("separator", ",")` and `bench/diff_test.py`
276/// hardcodes `","`. Both substitute the **base** default, so YI's own default
277/// is erased before the value reaches Rust. The corpus was frozen from the pure
278/// Python path, where the omitted kwarg correctly resolves to `" "`:
279///
280/// ```text
281/// {"lang": "yi", "to": "currency:EUR", "arg": "12.34",
282/// "out": "צען צוויי אייראָ דרײַסיק פיר צענט"}
283/// ```
284///
285/// — a space, not a comma. So [`separator_for`] reads an incoming `","` as
286/// "unsupplied" and restores YI's default. See the reported concerns: this is a
287/// boundary defect shared by every language whose `to_currency` overrides the
288/// separator default (DE `" und"`, ES `" y"`, BG `" и"`, ~50 more).
289const DEFAULT_SEPARATOR: &str = " ";
290
291/// `Num2Word_Base.to_currency`'s `separator=","`, which both call sites send
292/// verbatim when the caller supplies nothing. See [`DEFAULT_SEPARATOR`].
293const BASE_SEPARATOR_DEFAULT: &str = ",";
294
295/// Resolve the separator YI's Python signature would have applied.
296///
297/// An explicit `separator=","` is byte-identical to an omitted one by the time
298/// it arrives here, so it renders as `" "` — the one input this cannot get
299/// right, and one no implementation behind this ABI could. Every other explicit
300/// separator passes through untouched and stays faithful.
301fn separator_for(passed: &str) -> &str {
302 if passed == BASE_SEPARATOR_DEFAULT {
303 DEFAULT_SEPARATOR
304 } else {
305 passed
306 }
307}
308
309// ---- the str()-based float path -------------------------------------------
310//
311// `Num2Word_YI.to_cardinal` runs on `str(number)`, so the float path is pure
312// string work. These helpers reproduce CPython's `str()` byte-for-byte and its
313// `int()` failure modes; they are numeric formatters and carry no Yiddish word,
314// so they mirror the verified `lang_ceb` helpers exactly.
315
316/// Python's `int(s)` for a whole token. The error message quotes the offending
317/// literal exactly as CPython does, so an exponent-form repr (`"1e+16"`,
318/// `"1E-7"`) surfaces the identical `ValueError` text.
319fn py_int(s: &str) -> Result<BigInt> {
320 BigInt::from_str(s)
321 .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
322}
323
324/// Python's `int(ch)` for a *single* character, as the fraction loop calls it.
325///
326/// The message quotes the one offending character, matching CPython — that is
327/// why `1.5e+20` reports `'e'` (after already emitting the `5`) rather than
328/// `'5e+20'`. `char::to_digit(10)` is ASCII-only, but no `str(float)` /
329/// `str(Decimal)` can emit a non-ASCII digit, so the two agree everywhere the
330/// fraction loop can actually reach.
331fn py_int_digit(ch: char) -> Result<usize> {
332 ch.to_digit(10).map(|d| d as usize).ok_or_else(|| {
333 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
334 })
335}
336
337/// The shortest round-trip decimal digits of `a` (finite, non-negative), plus
338/// CPython's `decpt`: the value is `0.<digits> * 10^decpt`.
339///
340/// Rust's `{:e}` and CPython's `repr` are both "shortest string that reads back
341/// as the same double" and agree on the digit count always and the digits
342/// themselves almost always — they part company only on an **exact tie**, where
343/// CPython breaks to **even** and Rust's shortest `{:e}` breaks **away from
344/// zero** (e.g. the double `670352580196876.25` reprs `...876.2`, not `.3`).
345/// Since YI's whole fraction *is* the repr string, a wrong last digit is a
346/// wrong word. Rust's fixed-precision `{:.*e}` rounds half-to-even, so
347/// re-emitting the same significant-digit count applies CPython's tie rule; it
348/// is kept only if it still round-trips, otherwise we fall back to the shortest
349/// digits rather than emit a string that reads back as a different double.
350fn shortest_repr_digits(a: f64) -> (String, i32) {
351 let split = |s: &str| -> (String, i32) {
352 let (mant, exp) = s.split_once('e').expect("{:e} always emits an 'e'");
353 (
354 mant.chars().filter(|c| *c != '.').collect(),
355 // `{:e}` normalises to exactly one digit before the point, so the
356 // decimal point sits one place left of where CPython counts it.
357 exp.parse::<i32>().expect("{:e} exponent is an integer") + 1,
358 )
359 };
360
361 let shortest = format!("{:e}", a);
362 let (digits, decpt) = split(&shortest);
363
364 let ties_even = format!("{:.*e}", digits.len() - 1, a);
365 if ties_even.parse::<f64>() == Ok(a) {
366 return split(&ties_even);
367 }
368 (digits, decpt)
369}
370
371/// CPython's `str(float)` / `repr(float)` — where YI's float path begins.
372///
373/// `PyOS_double_to_string(v, 'r', 0, Py_DTSF_ADD_DOT_0)`: shortest round-trip
374/// digits, then switch to exponent form when `decpt <= -4 || decpt > 16`
375/// (`str(1e15) == "1000000000000000.0"` but `str(1e16) == "1e+16"`;
376/// `str(0.0001) == "0.0001"` but `str(1e-05) == "1e-05"`), formatting the
377/// exponent `%+.02d`; otherwise print positionally and append `.0` if nothing
378/// follows the point (the reason `1.0` is "איינס point zero", not "איינס").
379fn py_str_f64(v: f64) -> String {
380 // Unreachable from the shim (which derives `precision` from a finite
381 // Decimal repr), but handled so this is a faithful `str()` rather than a
382 // faithful-in-context one; `cardinal_from_repr` would then raise on
383 // `int("inf")`/`int("nan")`, exactly as Python does.
384 if v.is_nan() {
385 return "nan".to_string();
386 }
387 if v.is_infinite() {
388 return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
389 }
390
391 // Sign from the sign *bit*, not `v < 0.0`, so `str(-0.0) == "-0.0"` and the
392 // leading-"-" branch fires: -0.0 renders "minus zero point zero".
393 let sign = if v.is_sign_negative() { "-" } else { "" };
394 let (digits, decpt) = shortest_repr_digits(v.abs());
395 let ndig = digits.len() as i32;
396
397 if decpt <= -4 || decpt > 16 {
398 let mantissa = if ndig > 1 {
399 format!("{}.{}", &digits[..1], &digits[1..])
400 } else {
401 // No ADD_DOT_0 in exponent form: `str(1e16)` is "1e+16".
402 digits.clone()
403 };
404 let exp = decpt - 1;
405 let (esign, eabs) = if exp < 0 {
406 ("-", -(exp as i64))
407 } else {
408 ("+", exp as i64)
409 };
410 return format!("{}{}e{}{:0>2}", sign, mantissa, esign, eabs);
411 }
412 if decpt <= 0 {
413 // "0." + leading zeros + digits: 0.5 -> "0.5", 1e-4 -> "0.0001".
414 format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
415 } else if decpt >= ndig {
416 // Nothing after the point, so Py_DTSF_ADD_DOT_0 supplies ".0".
417 format!("{}{}{}.0", sign, digits, "0".repeat((decpt - ndig) as usize))
418 } else {
419 format!("{}{}.{}", sign, &digits[..decpt as usize], &digits[decpt as usize..])
420 }
421}
422
423/// CPython's `Decimal.__str__` (`to-scientific-string`), ported from
424/// `_pydecimal.Decimal.__str__`. `BigDecimal`'s own `Display` disagrees on
425/// `1E+2` (would give `100`), `0.0` (would give `0`) and the exponent's case,
426/// each of which would change YI's output or its exception, so the digits and
427/// exponent are read off `as_bigint_and_exponent()` and reassembled by Python's
428/// rule. `BigDecimal::from_str` keeps the written scale rather than normalising
429/// (`"1.10"` stays coefficient 110 / scale 2), which is what makes the trailing
430/// "zero" appear, and `(coefficient, -scale)` is exactly Python's `(_int, _exp)`.
431fn py_str_decimal(value: &BigDecimal) -> String {
432 // BigDecimal stores value = coefficient * 10^-scale, so Python's `_exp`
433 // (which counts the other way) is the negated scale.
434 let (coefficient, scale) = value.as_bigint_and_exponent();
435 let exp = -scale;
436 let sign = if coefficient.is_negative() { "-" } else { "" };
437 let int_digits = coefficient.abs().to_string();
438 let ndig = int_digits.len() as i64;
439
440 let leftdigits = exp + ndig;
441 let dotplace = if exp <= 0 && leftdigits > -6 {
442 leftdigits
443 } else {
444 1
445 };
446
447 let (intpart, fracpart) = if dotplace <= 0 {
448 (
449 "0".to_string(),
450 format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
451 )
452 } else if dotplace >= ndig {
453 (
454 format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
455 String::new(),
456 )
457 } else {
458 (
459 int_digits[..dotplace as usize].to_string(),
460 format!(".{}", &int_digits[dotplace as usize..]),
461 )
462 };
463
464 // `['e', 'E'][context.capitals]`, and the default context has capitals=1.
465 let exponent = if leftdigits == dotplace {
466 String::new()
467 } else {
468 format!("E{:+}", leftdigits - dotplace)
469 };
470
471 format!("{}{}{}{}", sign, intpart, fracpart, exponent)
472}
473
474pub struct LangYi {
475 /// `CURRENCY_FORMS`, built once. `get_lang` keeps the `LangYi` in a
476 /// `OnceLock` and hands out `&'static` refs, so this table is constructed
477 /// exactly once per process rather than per call.
478 forms: HashMap<&'static str, CurrencyForms>,
479 /// `list(self.CURRENCY_FORMS.values())[0]` — the entry `to_currency` falls
480 /// back to for an unknown code (module bug 7). Cloned out of `forms` rather
481 /// than re-spelled so the two cannot drift apart.
482 fallback_forms: CurrencyForms,
483}
484
485impl LangYi {
486 pub fn new() -> Self {
487 let mut forms = HashMap::new();
488 // Both EUR slots hold the same word; the arity (2) still matters
489 // because to_currency indexes cr1[0]/cr1[1] directly. See bug 11.
490 forms.insert("EUR", CurrencyForms::new(&[EURO, EURO], &[EURO_CENT, EURO_CENT]));
491 // USD is ASCII English, not Yiddish — see module bug 10.
492 forms.insert(
493 "USD",
494 CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
495 );
496 let fallback_forms = forms[FALLBACK_CODE].clone();
497 LangYi { forms, fallback_forms }
498 }
499
500 /// Python's `_int_to_word`.
501 ///
502 /// The `< 0` arm is dead from every in-scope caller (see module docs) but
503 /// is reproduced, including the quirk that `== 0` is tested first.
504 fn int_to_word(&self, number: &BigInt) -> String {
505 if number.is_zero() {
506 return ZERO.to_string();
507 }
508 if number.is_negative() {
509 return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
510 }
511 // Python's final `else: return str(number)`. Hoisted above the u64
512 // narrowing because it is what makes that narrowing sound.
513 if number >= &BigInt::from(FALLBACK_LIMIT) {
514 return number.to_string();
515 }
516 // Proven bounded: 0 < number < 10**9, so the value fits u64 and the
517 // cascade below can use fixed-width arithmetic without truncating.
518 let n = number
519 .to_u64()
520 .expect("0 < number < 10**9 always fits in u64");
521 self.int_to_word_small(n)
522 }
523
524 /// The word-producing cascade of `_int_to_word`, for `0 < n < 10**9`.
525 ///
526 /// Recurses on remainders only (never on 0 — Python guards each with
527 /// `if remainder:`), so `n == 0` cannot arrive here from `int_to_word`.
528 /// The `n == 0` guard is kept anyway to mirror Python's own entry check.
529 fn int_to_word_small(&self, n: u64) -> String {
530 if n == 0 {
531 return ZERO.to_string();
532 }
533 if n < 10 {
534 ONES[n as usize].to_string()
535 } else if n < 100 {
536 let tens_val = (n / 10) as usize;
537 let ones_val = (n % 10) as usize;
538 if ones_val == 0 {
539 // No teens table: 10 => "צען", 20 => "צוואַנציק". Bug 1.
540 TENS[tens_val].to_string()
541 } else {
542 // 11 => "צען איינס", 21 => "צוואַנציק איינס". Bug 1.
543 format!("{} {}", TENS[tens_val], ONES[ones_val])
544 }
545 } else if n < 1000 {
546 let hundreds_val = (n / 100) as usize;
547 let remainder = n % 100;
548 // Direct ONES index, not a recursive call — and no elision for
549 // hundreds_val == 1, so 100 => "איינס הונדערט". Bug 2.
550 let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
551 if remainder != 0 {
552 result.push(' ');
553 result.push_str(&self.int_to_word_small(remainder));
554 }
555 result
556 } else if n < 1_000_000 {
557 let thousands_val = n / 1000;
558 let remainder = n % 1000;
559 let mut result = format!("{} {}", self.int_to_word_small(thousands_val), THOUSAND);
560 if remainder != 0 {
561 result.push(' ');
562 result.push_str(&self.int_to_word_small(remainder));
563 }
564 result
565 } else {
566 // n < 10**9 — the caller already handled the str(number) fallback.
567 let millions_val = n / 1_000_000;
568 let remainder = n % 1_000_000;
569 let mut result = format!("{} {}", self.int_to_word_small(millions_val), MILLION);
570 if remainder != 0 {
571 result.push(' ');
572 result.push_str(&self.int_to_word_small(remainder));
573 }
574 result
575 }
576 }
577
578 /// Python's `Num2Word_YI.to_cardinal` operating on `str(number)` — the
579 /// form its own code works in for a non-integer. `n` is already the repr
580 /// ([`py_str_f64`] / [`py_str_decimal`] produced it):
581 ///
582 /// ```python
583 /// n = str(number).strip()
584 /// if n.startswith("-"):
585 /// n = n[1:]
586 /// ret = self.negword
587 /// else:
588 /// ret = ""
589 /// if "." in n:
590 /// left, right = n.split(".", 1)
591 /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
592 /// for digit in right:
593 /// ret += self._int_to_word(int(digit)) + " "
594 /// return ret.strip()
595 /// else:
596 /// return (ret + self._int_to_word(int(n))).strip()
597 /// ```
598 ///
599 /// The sign is stripped from the *string* before the dot check, so both
600 /// branches carry the raw `negword` ("minus ", not via `parse_minus`).
601 /// `split(".", 1)` splits on the first dot only; `int(left)` / `int(digit)`
602 /// raise `ValueError` on an exponent-form repr (`"1e+16"`) or a stray `'e'`.
603 /// `int(left)` inherits `int_to_word`'s `>= 10**9` digit-string fallback
604 /// (bug 13). `self.pointword` is used raw — no `title`, matching the inline
605 /// body (unlike `Num2Word_Base.to_cardinal_float`, which title-cases it).
606 fn cardinal_from_repr(&self, n: &str) -> Result<String> {
607 // n = str(number).strip(). Python strips its own whitespace set and
608 // Rust trims Unicode's; no repr contains either.
609 let n = n.trim();
610
611 // if n.startswith("-"): n = n[1:]; ret = self.negword; else: ret = ""
612 let (n, ret) = match n.strip_prefix('-') {
613 Some(rest) => (rest, NEGWORD),
614 None => (n, ""),
615 };
616
617 match n.split_once('.') {
618 // if "." in n:
619 Some((left, right)) => {
620 // ret += int_to_word(int(left)) + " " + pointword + " "
621 let mut out = String::from(ret);
622 out.push_str(&self.int_to_word(&py_int(left)?));
623 out.push(' ');
624 out.push_str(self.pointword());
625 out.push(' ');
626 // for digit in right: ret += int_to_word(int(digit)) + " "
627 for digit in right.chars() {
628 out.push_str(&self.int_to_word(&BigInt::from(py_int_digit(digit)?)));
629 out.push(' ');
630 }
631 // return ret.strip()
632 Ok(out.trim().to_string())
633 }
634 // else: return (ret + int_to_word(int(n))).strip()
635 None => Ok(format!("{}{}", ret, self.int_to_word(&py_int(n)?))
636 .trim()
637 .to_string()),
638 }
639 }
640}
641
642impl Default for LangYi {
643 fn default() -> Self {
644 Self::new()
645 }
646}
647
648impl Lang for LangYi {
649
650 fn cardinal_float_entry(
651 &self,
652 value: &crate::floatpath::FloatValue,
653 precision_override: Option<u32>,
654 ) -> crate::base::Result<String> {
655 // Python's to_cardinal routes every float/Decimal through this
656 // language's own decimal grammar — 5.0 keeps its ".0" tail
657 // ("comma nulla"), unlike Base's whole-value integer route.
658 self.to_cardinal_float(value, precision_override)
659 }
660
661 /// `to_ordinal(float/Decimal)`. YI's `to_ordinal` is
662 /// `self.to_cardinal(number) + "-טער"` for *every* input, so the float
663 /// entry is the float cardinal plus the suffix. An exponent-form Decimal
664 /// repr ("1E+2") still dies in `int()` with ValueError inside the
665 /// cardinal, before the suffix is appended.
666 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
667 Ok(format!(
668 "{}{}",
669 self.cardinal_float_entry(value, None)?,
670 ORDINAL_SUFFIX
671 ))
672 }
673
674 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — the repr the
675 /// binding computed, dot appended, sign and exponent form included
676 /// ("-0.0.", "1e+16.").
677 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
678 Ok(format!("{}.", repr_str))
679 }
680
681 /// `converter.str_to_number` — Base's `Decimal(value)`, with the Inf
682 /// interception: Python parses "Infinity" fine and the ValueError only
683 /// fires later, inside YI's `int("Infinity")` (`to_cardinal` reads
684 /// `str(number)`, strips the sign, finds no "." and calls `int()`).
685 /// The binding otherwise hard-codes `ParsedNumber::Inf` to the base
686 /// integer path's OverflowError before any YI code runs, so the
687 /// ValueError must be raised here. The sign is sliced off before
688 /// `int()` sees it, so both signs quote the same literal.
689 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
690 match python_decimal_parse(s)? {
691 ParsedNumber::Inf { .. } => Err(N2WError::Value(
692 "invalid literal for int() with base 10: 'Infinity'".into(),
693 )),
694 other => Ok(other),
695 }
696 }
697
698 /// This language's own `to_currency(currency=...)` default,
699 /// read from the live Python signature. Only 44 of 156 use EUR.
700 fn default_currency(&self) -> &str {
701 "EUR"
702 }
703
704 /// This language's own `to_currency(separator=...)` default,
705 /// read from the live Python signature. Base's is ",", but only
706 /// 36 of 149 languages actually use it — most default to " " or a
707 /// conjunction, so inheriting Base's comma silently corrupts them.
708 fn default_separator(&self) -> &str {
709 " "
710 }
711
712 fn negword(&self) -> &str {
713 NEGWORD
714 }
715
716 fn pointword(&self) -> &str {
717 "point"
718 }
719
720 // cards / maxval / merge stay at their trait defaults: Python never builds
721 // self.cards for YI (no *_numwords attributes), so splitnum/clean/merge are
722 // unreachable and MAXVAL does not exist. There is no overflow check.
723
724 /// Python's `to_cardinal`, integer path.
725 ///
726 /// Python works on `str(number)`: it strips a leading "-" from the *string*
727 /// and stashes `negword`, so `_int_to_word` only ever sees a non-negative
728 /// value. The float branch (`"." in n`) is out of scope. The trailing
729 /// `.strip()` is a no-op for integer input — `_int_to_word` never returns
730 /// leading or trailing whitespace — but is reproduced verbatim.
731 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
732 let (ret, magnitude) = if value.is_negative() {
733 (NEGWORD, value.abs())
734 } else {
735 ("", value.clone())
736 };
737 Ok(format!("{}{}", ret, self.int_to_word(&magnitude))
738 .trim()
739 .to_string())
740 }
741
742 /// Python's `to_cardinal`, float/Decimal path — the `"." in n` branch of
743 /// the same method, reached when the dispatcher hands over a non-integer.
744 /// There is no separate `to_cardinal_float` in `Num2Word_YI`; the body is
745 /// the inline string walk ported in [`LangYi::cardinal_from_repr`], fed by
746 /// an exact `str(number)` (see the module docs).
747 ///
748 /// `precision_override` (the `precision=` kwarg) is deliberately ignored:
749 /// `num2words(..., precision=N)` sets `converter.precision`, but
750 /// `Num2Word_YI.to_cardinal` takes no such parameter and never reads
751 /// `self.precision`. Verified inert against the live interpreter —
752 /// `precision=3` leaves `0.5` as "zero point פינף", not padded. The
753 /// `FloatValue`'s own `precision` field is likewise unread: the repr
754 /// string carries the digit count by construction.
755 fn to_cardinal_float(
756 &self,
757 value: &FloatValue,
758 _precision_override: Option<u32>,
759 ) -> Result<String> {
760 // n = str(number) — the float and Decimal arms are not interchangeable
761 // (issue #603): `str` of a Decimal keeps every written digit, so
762 // Decimal("1.10") ends in a trailing "zero" the float 1.1 could never
763 // express, and Decimal("98746251323029.99") keeps its trillion-scale
764 // integer part exact where a float cast would have rounded it.
765 let n = match value {
766 FloatValue::Float { value, .. } => py_str_f64(*value),
767 FloatValue::Decimal { value, .. } => py_str_decimal(value),
768 };
769 self.cardinal_from_repr(&n)
770 }
771
772 /// Python's `to_ordinal`: `self.to_cardinal(number) + "-טער"`.
773 ///
774 /// No `verify_ordinal` call, so negatives pass through; no last-word
775 /// inflection, so the suffix lands on the digit-string fallback and on the
776 /// English "zero" too. Bugs 3, 4 and 6.
777 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
778 let cardinal = self.to_cardinal(value)?;
779 Ok(format!("{}{}", cardinal, ORDINAL_SUFFIX))
780 }
781
782 /// Python's `to_ordinal_num`: `str(number) + "."`.
783 ///
784 /// Overrides `Num2Word_Base.to_ordinal_num` (which returns the value
785 /// unchanged), so the trait default is *not* correct here — the trailing
786 /// dot is required. The sign is kept: `to_ordinal_num(-1)` == "-1.".
787 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
788 Ok(format!("{}.", value))
789 }
790
791 /// Python's `to_year(val, longval=True)`: `return self.to_cardinal(val)`.
792 ///
793 /// `longval` is accepted and ignored — no century splitting, no "hundred"
794 /// idiom. Same result as the trait default; spelled out because YI does
795 /// physically override the method.
796 fn to_year(&self, value: &BigInt) -> Result<String> {
797 self.to_cardinal(value)
798 }
799
800 // ---- currency ----------------------------------------------------
801
802 fn lang_name(&self) -> &str {
803 LANG_NAME
804 }
805
806 /// `CURRENCY_FORMS[code]`, with **no** fallback.
807 ///
808 /// This is the strict lookup, and it is what the inherited `to_cheque`
809 /// needs: `Num2Word_Base.to_cheque` does `self.CURRENCY_FORMS[currency]`
810 /// and converts the `KeyError` into `NotImplementedError`, so returning
811 /// `None` here makes `currency::default_to_cheque` raise the identical
812 /// message for GBP/JPY/KWD/BHD/INR/CNY/CHF.
813 ///
814 /// `to_currency` deliberately does *not* route through this — it applies
815 /// the EUR fallback instead. That asymmetry is module bug 7.
816 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
817 self.forms.get(code)
818 }
819
820 // currency_precision stays at the trait default of 100: Num2Word_YI never
821 // defines CURRENCY_PRECISION and Num2Word_Base's is `{}`, so
822 // `.get(code, 100)` is 100 for every code — including the ones that are
823 // 3-decimal (KWD/BHD) or 0-decimal (JPY) elsewhere. to_cheque therefore
824 // prints "56/100" for KWD, and to_currency ignores precision outright.
825 //
826 // currency_adjective stays at the default (None): CURRENCY_ADJECTIVES is
827 // `{}`, and to_currency ignores its `adjective` argument regardless.
828 //
829 // pluralize stays at the default (raises NotImplementedError), exactly as
830 // Num2Word_Base.pluralize does — Num2Word_YI never overrides it and
831 // nothing reachable calls it: to_currency inlines its own
832 // `cr1[1] if left != 1 else cr1[0]`, and to_cheque takes `cr1[-1]`
833 // unconditionally.
834 //
835 // money_verbose / cents_verbose / cents_terse stay at their defaults
836 // (`self.to_cardinal(number)`), which is what Num2Word_Base defines.
837 // to_cheque reaches money_verbose; to_currency calls _int_to_word directly
838 // and reaches none of them.
839
840 /// Python's `Num2Word_YI.to_currency` — a wholesale override that shares
841 /// no code with `Num2Word_Base.to_currency`.
842 ///
843 /// ```python
844 /// is_negative = False
845 /// if val < 0:
846 /// is_negative = True
847 /// val = abs(val)
848 /// parts = str(val).split(".")
849 /// left = int(parts[0]) if parts[0] else 0
850 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
851 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
852 /// left_str = self._int_to_word(left)
853 /// result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
854 /// if cents and right:
855 /// cents_str = self._int_to_word(right)
856 /// result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
857 /// if is_negative:
858 /// result = self.negword + result
859 /// return result.strip()
860 /// ```
861 ///
862 /// `adjective` is in the signature and never read — hence `_adjective`.
863 fn to_currency(
864 &self,
865 val: &CurrencyValue,
866 currency: &str,
867 cents: bool,
868 separator: Option<&str>,
869 _adjective: bool,
870 ) -> Result<String> {
871 // Trait now hands us None when the caller omitted separator=;
872 // resolve it to this language's own default before the ported body.
873 let separator = separator.unwrap_or(self.default_separator());
874 let is_negative = val.is_negative();
875
876 // Python takes abs() *first*, then splits str(val) on ".".
877 let (left, right) = match val {
878 // `str(int)` never contains a ".", so `len(parts) > 1` is False
879 // and `right` stays 0. An int can never print cents — though here
880 // that is incidental rather than the deliberate
881 // `isinstance(val, int)` branch the base class uses (bug 9).
882 CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
883 CurrencyValue::Decimal { value: d, .. } => {
884 let v = d.abs();
885 // `int(parts[0])` — the digits left of the point, i.e. floor
886 // (v is already non-negative), matching with_scale(0)'s
887 // truncation.
888 let integer = v.with_scale(0);
889 let frac = &v - &integer;
890 // `int(parts[1][:2].ljust(2, "0"))` — two decimal digits,
891 // padded on the right and truncated beyond, which is exactly
892 // floor(frac * 100): "5" -> 50, "239" -> 23, "005" -> 0.
893 // Truncation, never rounding. See module bug 8.
894 let right = (frac * BigDecimal::from(100)).with_scale(0);
895 (
896 integer.as_bigint_and_exponent().0,
897 right.as_bigint_and_exponent().0,
898 )
899 }
900 };
901
902 // `.get(currency, <first value>)` — an unknown code borrows EUR's
903 // forms rather than raising. See module bug 7.
904 let forms = self.forms.get(currency).unwrap_or(&self.fallback_forms);
905
906 let one = BigInt::one();
907 let mut result = format!(
908 "{} {}",
909 self.int_to_word(&left),
910 if left == one { &forms.unit[0] } else { &forms.unit[1] }
911 );
912
913 // `if cents and right:` — truthiness, so right == 0 drops the segment
914 // even for a float. This is why 1.0 has no cents. See module bug 9.
915 if cents && !right.is_zero() {
916 result.push_str(separator_for(separator));
917 result.push_str(&self.int_to_word(&right));
918 result.push(' ');
919 result.push_str(if right == one {
920 &forms.subunit[0]
921 } else {
922 &forms.subunit[1]
923 });
924 }
925
926 if is_negative {
927 // The raw negword ("minus ", trailing space), not parse_minus.
928 result = format!("{}{}", NEGWORD, result);
929 }
930 // `.strip()` is a no-op — _int_to_word never returns padding and no
931 // form is empty — but is reproduced verbatim.
932 Ok(result.trim().to_string())
933 }
934
935 // to_cheque is NOT overridden: Num2Word_YI inherits
936 // Num2Word_Base.to_cheque, which currency::default_to_cheque already
937 // ports. It reaches YI only through lang_name / currency_forms /
938 // currency_precision / money_verbose above, and needs no help:
939 // to_cheque(1234.56, "EUR") -> whole=1234, sub=56, divisor=100
940 // -> "<to_cardinal(1234)> AND 56/100 אייראָ".upper()
941 // Rust's str::to_uppercase leaves Hebrew-script text alone exactly as
942 // Python's str.upper() does, so only the ASCII forms shift:
943 // "dollars" -> "DOLLARS", and the literal "and" -> "AND".
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949
950 fn f(v: f64, precision: u32) -> Result<String> {
951 LangYi::new().to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
952 }
953
954 fn d(s: &str, precision: u32) -> Result<String> {
955 LangYi::new().to_cardinal_float(
956 &FloatValue::Decimal {
957 value: BigDecimal::from_str(s).unwrap(),
958 precision,
959 },
960 None,
961 )
962 }
963
964 fn value_err(r: Result<String>) -> String {
965 match r {
966 Err(N2WError::Value(m)) => m,
967 other => panic!("expected ValueError, got {:?}", other),
968 }
969 }
970
971 /// Every `"to": "cardinal"` row in bench/corpus.jsonl whose arg is a float.
972 #[test]
973 fn corpus_float_rows() {
974 assert_eq!(f(0.0, 1).unwrap(), "zero point zero");
975 assert_eq!(f(0.5, 1).unwrap(), "zero point פינף");
976 assert_eq!(f(1.0, 1).unwrap(), "איינס point zero");
977 assert_eq!(f(1.5, 1).unwrap(), "איינס point פינף");
978 assert_eq!(f(2.25, 2).unwrap(), "צוויי point צוויי פינף");
979 assert_eq!(f(3.14, 2).unwrap(), "דרײַ point איינס פיר");
980 assert_eq!(f(0.01, 2).unwrap(), "zero point zero איינס");
981 assert_eq!(f(0.1, 1).unwrap(), "zero point איינס");
982 assert_eq!(f(0.99, 2).unwrap(), "zero point נײַן נײַן");
983 assert_eq!(f(1.01, 2).unwrap(), "איינס point zero איינס");
984 assert_eq!(f(12.34, 2).unwrap(), "צען צוויי point דרײַ פיר");
985 assert_eq!(f(99.99, 2).unwrap(), "נײַנציק נײַן point נײַן נײַן");
986 assert_eq!(f(100.5, 1).unwrap(), "איינס הונדערט point פינף");
987 assert_eq!(
988 f(1234.56, 2).unwrap(),
989 "איינס טויזנט צוויי הונדערט דרײַסיק פיר point פינף זעקס"
990 );
991 assert_eq!(f(-0.5, 1).unwrap(), "minus zero point פינף");
992 assert_eq!(f(-1.5, 1).unwrap(), "minus איינס point פינף");
993 assert_eq!(f(-12.34, 2).unwrap(), "minus צען צוויי point דרײַ פיר");
994 assert_eq!(f(1.005, 3).unwrap(), "איינס point zero zero פינף");
995 assert_eq!(f(2.675, 3).unwrap(), "צוויי point זעקס זיבן פינף");
996 }
997
998 /// Every `"to": "cardinal_dec"` row in bench/corpus.jsonl.
999 #[test]
1000 fn corpus_decimal_rows() {
1001 assert_eq!(d("0.01", 2).unwrap(), "zero point zero איינס");
1002 // The trailing "zero" is the point of this row: Decimal("1.10") keeps
1003 // its written scale, which the float 1.1 could never express.
1004 assert_eq!(d("1.10", 2).unwrap(), "איינס point איינס zero");
1005 assert_eq!(d("12.345", 3).unwrap(), "צען צוויי point דרײַ פיר פינף");
1006 // Issue #603: the exact-Decimal arm at trillion scale. The integer
1007 // part exceeds 10**9, so bug 3 hands back bare digits (bug 13).
1008 assert_eq!(
1009 d("98746251323029.99", 2).unwrap(),
1010 "98746251323029 point נײַן נײַן"
1011 );
1012 assert_eq!(d("0.001", 3).unwrap(), "zero point zero zero איינס");
1013 }
1014
1015 /// Non-corpus rows verified against the live interpreter.
1016 #[test]
1017 fn live_interpreter_rows() {
1018 assert_eq!(
1019 f(1234567.89, 2).unwrap(),
1020 "איינס מיליאָן צוויי הונדערט דרײַסיק פיר טויזנט פינף הונדערט זעכציק זיבן point אַכט נײַן"
1021 );
1022 // Integer part >= 10**9: the digit-string fallback (bugs 3/13).
1023 assert_eq!(f(1500000000.25, 2).unwrap(), "1500000000 point צוויי פינף");
1024 // str(-0.0) == "-0.0": the sign bit survives repr, so negword fires.
1025 assert_eq!(f(-0.0, 1).unwrap(), "minus zero point zero");
1026 // A negative Decimal takes the same string-level "-" strip.
1027 assert_eq!(d("-12.34", 2).unwrap(), "minus צען צוויי point דרײַ פיר");
1028 }
1029
1030 /// The f64-artefact cases. YI reads `str()`, not `float2tuple`, so the
1031 /// repr's digits are used verbatim — no `10**precision`, no `< 0.01`
1032 /// rescue, no banker's rounding anywhere on this path.
1033 #[test]
1034 fn f64_artefacts_come_from_repr_not_float2tuple() {
1035 assert_eq!(f(1.005, 3).unwrap(), "איינס point zero zero פינף");
1036 assert_eq!(f(2.675, 3).unwrap(), "צוויי point זעקס זיבן פינף");
1037 // repr keeps all 17 fractional digits of 0.1+0.2 ("0.30000000000000004")
1038 // and every one of them becomes a word — no rounding, no truncation.
1039 // Built rather than spelled out so the zero count cannot drift.
1040 let expected = format!("zero point דרײַ {} פיר", ["zero"; 15].join(" "));
1041 assert_eq!(f(0.1 + 0.2, 17).unwrap(), expected);
1042 }
1043
1044 /// `precision=` sets `converter.precision`, which YI never reads.
1045 #[test]
1046 fn precision_override_is_inert() {
1047 let l = LangYi::new();
1048 let v = FloatValue::Float { value: 2.675, precision: 3 };
1049 for over in [None, Some(0), Some(1), Some(2), Some(5), Some(17)] {
1050 assert_eq!(
1051 l.to_cardinal_float(&v, over).unwrap(),
1052 "צוויי point זעקס זיבן פינף"
1053 );
1054 }
1055 }
1056
1057 /// Bug 13's companion: a repr in exponent form reaches `int()` and raises,
1058 /// with CPython's exact message. Verified against the live interpreter.
1059 #[test]
1060 fn exponent_form_repr_raises_value_error() {
1061 // 1e15 still prints positionally, so it survives (as digits, by bug 3).
1062 assert_eq!(f(1e15, 1).unwrap(), "1000000000000000 point zero");
1063 // One decade up, decpt > 16 and repr flips to "1e+16" — no "." at all,
1064 // so the whole token hits int().
1065 assert_eq!(
1066 value_err(f(1e16, 0)),
1067 "invalid literal for int() with base 10: '1e+16'"
1068 );
1069 // The "-" is stripped from the *string* first, so the message quotes
1070 // the unsigned literal.
1071 assert_eq!(
1072 value_err(f(-1e16, 0)),
1073 "invalid literal for int() with base 10: '1e+16'"
1074 );
1075 assert_eq!(
1076 value_err(f(1e-5, 0)),
1077 "invalid literal for int() with base 10: '1e-05'"
1078 );
1079 // "1.5e+20" keeps a ".", so the crash comes from the fraction loop on
1080 // the single char 'e' — after the '5' was already emitted.
1081 assert_eq!(
1082 value_err(f(1.5e20, 0)),
1083 "invalid literal for int() with base 10: 'e'"
1084 );
1085 // Decimal.__str__ goes scientific at leftdigits <= -6: "1E-7".
1086 assert_eq!(
1087 value_err(d("1E-7", 7)),
1088 "invalid literal for int() with base 10: '1E-7'"
1089 );
1090 }
1091}