num2words2_core/lang_he.rs
1//! Port of `lang_HE.py` (Hebrew).
2//!
3//! Shape: **self-contained**. `Num2Word_HE` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards`. It overrides `to_cardinal` outright and drives the
6//! module-level `int2word`/`chunk2word` pair over 3-digit chunks, so
7//! `cards`/`merge` stay at their trait defaults here. `MAXVAL` *is* set (by
8//! `setup()`, to 10**66), so the overflow check is real — see [`LangHe::maxval`].
9//!
10//! Inherited from `Num2Word_Base` unchanged, so the trait defaults are correct:
11//! * `to_ordinal_num(value) -> value` → default `Ok(value.to_string())`.
12//! Note it does *not* call `verify_ordinal`, so negatives pass through:
13//! `to_ordinal_num(-1)` == "-1".
14//! * `to_year(value, **kwargs) -> self.to_cardinal(value)` → the default
15//! delegates through `&self` and picks up the `to_cardinal` override below,
16//! with `gender="f"`. Hebrew years are therefore identical to cardinals.
17//! * `verify_ordinal` → the float branch (`value == int(value)`) raises
18//! `TypeError` (`errmsg_floatord`), the negative branch `TypeError`
19//! (`errmsg_negord`). Reproduced inline in [`LangHe::to_ordinal`] (BigInt:
20//! only the negative branch can fire) and in the `ordinal_float_entry`
21//! override (float/Decimal: both). Note `abs(-0.0) == -0.0` is *True* in
22//! Python (value comparison, not sign bit), so `to_ordinal(-0.0)` is
23//! "האפס" — the entry therefore keys off the numeric sign of the
24//! converted int, not `FloatValue::is_negative`.
25//!
26//! # Grammatical kwargs
27//!
28//! Python signatures: `to_cardinal(value, gender="f", construct=False)`,
29//! `to_ordinal(value, gender="m", definite=False, plural=False)`,
30//! `to_currency(val, currency="ILS", cents=True, separator=AND,
31//! adjective=False, prefer_singular=False, prefer_singular_cents=False)`.
32//! Ported as `to_cardinal_kw` / `to_ordinal_kw` / `to_currency_kw`:
33//!
34//! * `gender` is only ever compared with `== "m"`, so *any* other value —
35//! "f", "x", even an explicit `None` — selects the feminine forms. An
36//! explicit `gender=None` is therefore **not** treated like the ordinal's
37//! "m" default.
38//! * `construct`/`definite`/`plural` are used arithmetically in `chunk2word`
39//! (`2 * plural`, `2 * (construct and i == 0)`), so a non-bool value would
40//! change indexes or raise (`2 * None` → TypeError). Only real bools are
41//! handled here; anything else returns NotImplemented so the dispatcher
42//! falls back to Python, which reproduces the exotic behaviour itself.
43//! * `prefer_singular` / `prefer_singular_cents` are accepted by HE's
44//! `to_currency` and then never read (the body forwards neither, and
45//! `pluralize`'s `prefer_singular` limb is unreachable — see
46//! [`LangHe::pluralize`]). Any value is a no-op, so `to_currency_kw`
47//! simply delegates to the plain `to_currency`.
48//! * `_money_verbose` / `_cents_verbose` → `self.to_cardinal(number)`, and
49//! `_cents_terse` → zero-padded digits. All three trait defaults already
50//! do this, and route through the `to_cardinal` override.
51//! * `to_cheque` → `currency::default_to_cheque`. `.upper()` is a no-op on
52//! Hebrew, so only the literal "AND"/"MINUS" are upper-case in the output.
53//! * `CURRENCY_ADJECTIVES` / `CURRENCY_PRECISION` are both `{}` — HE descends
54//! from `Num2Word_Base`, not `Num2Word_EUR`, so it inherits neither EUR's
55//! adjective table nor EN's mils precisions (verified against the live
56//! interpreter). Every code is therefore divisor 100, and `adjective=True`
57//! is a no-op. Trait defaults (`None` / 100) already say exactly that.
58//!
59//! # The currency surface
60//!
61//! `CURRENCY_FORMS` is `Num2Word_HE`'s own class attribute, so the
62//! `lang_EUR.py` mutation trap does not apply: EN rewrites
63//! `Num2Word_EUR.CURRENCY_FORMS` in place, but HE neither inherits from
64//! `Num2Word_EUR` nor is touched by EN. The live table is exactly the three
65//! codes in [`build_currency_forms`] — anything else is a `NotImplementedError`.
66//!
67//! `Num2Word_HE.to_currency` is the interesting part. It splits on
68//! `isinstance(val, int)` and, for a true int, **casts to `float(val)` before
69//! delegating to `Num2Word_Base.to_currency`**. That inverts Base's usual
70//! int/float split: Base skips the cents segment for ints, but HE never lets
71//! Base see an int, so `to_currency(0, "EUR")` renders "אפס אירוו אפס סנטים"
72//! — with cents. Then it scrubs the now-redundant zero-cents text back out
73//! with a list of hardcoded patterns. See [`strip_zero_cents`].
74//!
75//! `CURRENCY_GENDERS` and `__init__`'s `makaf` are both dead data in Python —
76//! assigned and never read by any code path — so neither is ported. They are
77//! called out here because a reviewer diffing against `lang_HE.py` will look
78//! for them.
79//!
80//! # Hebrew-specific behaviour worth knowing
81//!
82//! * **Cardinals are feminine, ordinals are masculine.** `to_cardinal` defaults
83//! to `gender="f"` and `to_ordinal` to `gender="m"`. That is why 1 is "אחת"
84//! as a cardinal but the ordinal of 2 is "שני".
85//! * **Gender flips above the units chunk.** `male = gender == "m" or i > 0`:
86//! any chunk above the last one (thousands, millions, …) is forced masculine
87//! regardless of the caller's gender. So 123456 cardinal has masculine
88//! "שלושה" in the thousands chunk but feminine "שש" in the units chunk.
89//! * **The `cop` offset only applies below 11.** `cop = (…4 * ordinal…) * (n < 11)`
90//! keys off the *whole* number `n`, not the chunk, so true ordinal wordforms
91//! ("ראשון", "עשירי") appear only for n in 1..=10. For n >= 11 the ordinal is
92//! just the cardinal with a definite "ה" glued on: `to_ordinal(11)` ==
93//! "האחד עשר", literally "the eleven".
94//! * **The "ו" (and) conjunction is applied per chunk, not once at the end.**
95//! `int2word` prefixes AND to `words[-1]` after *every* non-zero chunk, so a
96//! number can carry several: 99999 == "תשעים ותשעה אלף תשע מאות תשעים ותשע".
97//! * **Hundreds are always feminine.** The `ONES[n3][0]` in the hundreds branch
98//! ignores gender entirely, hence ordinal 300 == "השלוש מאות".
99//!
100//! # Faithfully reproduced Python quirks
101//!
102//! 1. `chunk2word`'s construct-state hundreds test is `construct and n == 100`,
103//! comparing against the **whole number** `n` rather than the current chunk
104//! `x`. A construct-state 100100 would therefore miss the "מאת" form. Kept
105//! verbatim; unreachable from the four in-scope entry points because none of
106//! them passes `construct=True` (see the `construct` note in the report).
107//! 2. `HUNDREDS[n3][1]` in that same branch would raise `KeyError` for n3 >= 4
108//! and `IndexError` for n3 == 2 or 3 (those tuples are 1-long). Modelled by
109//! [`hundreds`] rather than being tidied away — same reasoning as (1).
110//! 3. `to_ordinal(0)` returns "האפס" ("the zero") rather than raising: the
111//! `n == 0` early-out in `int2word` prepends DEF when `ordinal` is set.
112//!
113//! # Bounds
114//!
115//! `MAXVAL` is 10**66 and `LARGE` tops out at key 20 ("ויגינטיליון"). The
116//! largest permitted n has 66 digits → 22 chunks → max chunk index `i` == 21 →
117//! `LARGE[20]`. The table therefore cannot be over-indexed while the MAXVAL
118//! guard holds; [`large`] still returns `N2WError::Key` rather than panicking.
119
120use crate::base::{KwVal, Kwargs, Lang, N2WError, Result};
121use crate::currency::{CurrencyForms, CurrencyValue};
122use crate::floatpath::FloatValue;
123use bigdecimal::BigDecimal;
124use num_bigint::BigInt;
125use num_traits::{One, Signed, Zero};
126use std::collections::HashMap;
127use std::sync::OnceLock;
128
129const ZERO: &str = "אפס";
130const AND: &str = "ו";
131const DEF: &str = "ה";
132const NEGWORD: &str = "מינוס";
133const POINTWORD: &str = "נקודה";
134
135/// `THOUSANDS[1][0]`.
136const THOUSANDS1: &str = "אלף";
137/// `THOUSANDS[2][0]`.
138const THOUSANDS2: &str = "אלפיים";
139/// `THOUSANDS[3]`, two forms: absolute, construct.
140const THOUSANDS3: [&str; 2] = ["אלפים", "אלפי"];
141
142const ONES: [[&str; 8]; 10] = [
143 ["", "", "", "", "", "", "", ""], // 0: absent in Python (never indexed)
144 ["אחת", "אחד", "אחת", "אחד", "ראשונה", "ראשון", "ראשונות", "ראשונים"],
145 ["שתיים", "שניים", "שתי", "שני", "שנייה", "שני", "שניות", "שניים"],
146 ["שלוש", "שלושה", "שלוש", "שלושת", "שלישית", "שלישי", "שלישיות", "שלישיים"],
147 ["ארבע", "ארבעה", "ארבע", "ארבעת", "רביעית", "רביעי", "רביעיות", "רביעיים"],
148 ["חמש", "חמישה", "חמש", "חמשת", "חמישית", "חמישי", "חמישיות", "חמישיים"],
149 ["שש", "שישה", "שש", "ששת", "שישית", "שישי", "שישיות", "שישיים"],
150 ["שבע", "שבעה", "שבע", "שבעת", "שביעית", "שביעי", "שביעיות", "שביעיים"],
151 ["שמונה", "שמונה", "שמונה", "שמונת", "שמינית", "שמיני", "שמיניות", "שמיניים"],
152 ["תשע", "תשעה", "תשע", "תשעת", "תשיעית", "תשיעי", "תשיעיות", "תשיעיים"],
153];
154
155/// `TENS[0]`, eight forms.
156const TENS0: [&str; 8] = ["עשר", "עשרה", "עשר", "עשרת", "עשירית", "עשירי", "עשיריות", "עשיריים"];
157/// `TENS[1]`, two forms (feminine, masculine).
158const TENS1: [&str; 2] = ["עשרה", "עשר"];
159/// `TENS[2]`, two forms (feminine, masculine).
160const TENS2: [&str; 2] = ["שתים עשרה", "שנים עשר"];
161
162const TWENTIES: [&str; 10] = [
163 "", "", // 0, 1: absent in Python (guarded by `n2 > 1`)
164 "עשרים",
165 "שלושים",
166 "ארבעים",
167 "חמישים",
168 "שישים",
169 "שבעים",
170 "שמונים",
171 "תשעים",
172];
173
174const LARGE: [(&str, &str); 21] = [
175 ("", ""), // 0: absent in Python
176 ("מיליון", "מיליוני"),
177 ("מיליארד", "מיליארדי"),
178 ("טריליון", "טריליוני"),
179 ("קוודריליון", "קוודריליוני"),
180 ("קווינטיליון", "קווינטיליוני"),
181 ("סקסטיליון", "סקסטיליוני"),
182 ("ספטיליון", "ספטיליוני"),
183 ("אוקטיליון", "אוקטיליוני"),
184 ("נוניליון", "נוניליוני"),
185 ("דסיליון", "דסיליוני"),
186 ("אונדסיליון", "אונדסיליוני"),
187 ("דואודסיליון", "דואודסיליוני"),
188 ("טרדסיליון", "טרדסיליוני"),
189 ("קווטואורדסיליון", "קווטואורדסיליוני"),
190 ("קווינדסיליון", "קווינדסיליוני"),
191 ("סקסדסיליון", "סקסדסיליוני"),
192 ("ספטנדסיליון", "ספטנדסיליוני"),
193 ("אוקטודסיליון", "אוקטודסיליוני"),
194 ("נובמדסיליון", "נובמדסיליוני"),
195 ("ויגינטיליון", "ויגינטיליוני"),
196];
197
198/// `HUNDREDS = {1: ("מאה", "מאת"), 2: ("מאתיים",), 3: ("מאות",)}`.
199///
200/// Modelled as a lookup rather than an array because the ragged tuple lengths
201/// are load-bearing: Python raises `KeyError` for an absent key and
202/// `IndexError` for an out-of-range form, and `chunk2word` has a branch
203/// (`construct and n == 100`) that can reach both. See quirk (2) in the module
204/// docs.
205fn hundreds(key: u32, idx: usize) -> Result<&'static str> {
206 let row: &[&str] = match key {
207 1 => &["מאה", "מאת"],
208 2 => &["מאתיים"],
209 3 => &["מאות"],
210 _ => return Err(N2WError::Key(format!("{}", key))),
211 };
212 row.get(idx)
213 .copied()
214 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
215}
216
217/// `THOUSANDS[key][0]` for the small keys. Reached only with `key` in 1..=2
218/// (guarded by `n1 != 0 && n1 <= 2`); key 3 is spelled out for completeness.
219fn thousands_small(key: u32) -> Result<&'static str> {
220 match key {
221 1 => Ok(THOUSANDS1),
222 2 => Ok(THOUSANDS2),
223 3 => Ok(THOUSANDS3[0]),
224 _ => Err(N2WError::Key(format!("{}", key))),
225 }
226}
227
228/// `LARGE[key][idx]`. `key` 0 is absent in the Python dict.
229fn large(key: usize, idx: usize) -> Result<&'static str> {
230 if key == 0 || key >= LARGE.len() {
231 return Err(N2WError::Key(format!("{}", key)));
232 }
233 let (abs, construct) = LARGE[key];
234 match idx {
235 0 => Ok(abs),
236 1 => Ok(construct),
237 _ => Err(N2WError::Index("tuple index out of range".into())),
238 }
239}
240
241/// Index of the last element, or Python's `IndexError` on an empty list.
242///
243/// Python writes `words[-1]`, which throws on an empty list. Every call site
244/// here is provably non-empty (a chunk with `x >= 11` always appends at least
245/// one word first), but the check keeps a Rust panic off the table.
246fn last_index(words: &[String]) -> Result<usize> {
247 words
248 .len()
249 .checked_sub(1)
250 .ok_or_else(|| N2WError::Index("list index out of range".into()))
251}
252
253/// `utils.splitbyx(s, 3)` — split a digit string into 3-digit chunks, the
254/// leftmost possibly short.
255///
256/// `s` is always the decimal form of a non-negative integer here (`int2word`
257/// is only ever handed `abs(value)`), so every slice parses and each chunk
258/// fits in a `u32`.
259fn splitbyx3(s: &str) -> Vec<u32> {
260 let length = s.len();
261 let x = 3usize;
262 let mut out = Vec::new();
263 if length > x {
264 let start = length % x;
265 if start > 0 {
266 out.push(s[..start].parse::<u32>().unwrap_or(0));
267 }
268 let mut i = start;
269 while i < length {
270 let end = (i + x).min(length);
271 out.push(s[i..end].parse::<u32>().unwrap_or(0));
272 i += x;
273 }
274 } else {
275 out.push(s.parse::<u32>().unwrap_or(0));
276 }
277 out
278}
279
280/// `utils.get_digits(x)` → `(n1, n2, n3)` = (units, tens, hundreds).
281///
282/// Python formats `"%03d" % n`, takes the last 3 characters and reverses them.
283/// `x` is always a chunk in 0..=999 here, so `"%03d"` is exactly 3 digits and
284/// the slice is a no-op — plain arithmetic is equivalent.
285fn get_digits(x: u32) -> (u32, u32, u32) {
286 (x % 10, (x / 10) % 10, (x / 100) % 10)
287}
288
289/// Python's module-level `chunk2word`.
290///
291/// `n` is the whole number; `i` the chunk index counting down from the top
292/// (0 == units chunk); `x` the chunk value in 0..=999. `gender_m` is Python's
293/// `gender == "m"`.
294#[allow(clippy::too_many_arguments)]
295fn chunk2word(
296 n: &BigInt,
297 i: usize,
298 x: u32,
299 gender_m: bool,
300 construct: bool,
301 ordinal: bool,
302 plural: bool,
303) -> Result<Vec<String>> {
304 let mut words: Vec<String> = Vec::new();
305 let (n1, n2, n3) = get_digits(x);
306
307 if n3 > 0 {
308 if construct && n == &BigInt::from(100) {
309 // Quirk (1)+(2): compares the whole `n`, and can raise Key/Index.
310 words.push(hundreds(n3, 1)?.to_string());
311 } else if n3 <= 2 {
312 words.push(hundreds(n3, 0)?.to_string());
313 } else {
314 // Hundreds always take the feminine ONES form, whatever the gender.
315 words.push(format!("{} {}", ONES[n3 as usize][0], hundreds(3, 0)?));
316 }
317 }
318
319 if n2 > 1 {
320 words.push(TWENTIES[n2 as usize].to_string());
321 }
322
323 if i == 0 || x >= 11 {
324 // Python: `male = gender == "m" or i > 0` — a bool used as an index.
325 let male = usize::from(gender_m || i > 0);
326 // Python: `cop = (2 * (construct and i == 0) + 4 * ordinal + 2 * plural) * (n < 11)`
327 // Note the `n < 11` keys off the whole number, not the chunk.
328 let cop = (2 * usize::from(construct && i == 0)
329 + 4 * usize::from(ordinal)
330 + 2 * usize::from(plural))
331 * usize::from(n < &BigInt::from(11));
332 if n2 == 1 {
333 if n1 == 0 {
334 // Python indexes TENS[n1], i.e. the literal key 0 in this branch.
335 words.push(TENS0[male + cop].to_string());
336 } else if n1 == 2 {
337 // TENS[2] has only 2 forms; Python indexes it with `male` alone.
338 words.push(TENS2[male].to_string());
339 } else {
340 words.push(format!("{} {}", ONES[n1 as usize][male], TENS1[male]));
341 }
342 } else if n1 > 0 {
343 words.push(ONES[n1 as usize][male + cop].to_string());
344 }
345 }
346
347 // Python: `construct_last = construct and (n % 1000**i == 0)` — evaluates
348 // to the bool `False` whenever `construct` is False, which is every call
349 // reachable from the four in-scope entry points.
350 let construct_last =
351 construct && (n % BigInt::from(1000u32).pow(i as u32)).is_zero();
352 let cl = usize::from(construct_last);
353
354 if i == 1 {
355 if x >= 11 {
356 let last = last_index(&words)?;
357 words[last].push(' ');
358 words[last].push_str(THOUSANDS1);
359 } else if n1 == 0 {
360 // x can only be 10 here (x != 0, x < 11, n1 == 0).
361 words.push(format!("{} {}", TENS0[3], THOUSANDS3[cl]));
362 } else if n1 <= 2 {
363 words.push(thousands_small(n1)?.to_string());
364 } else {
365 words.push(format!("{} {}", ONES[n1 as usize][3], THOUSANDS3[cl]));
366 }
367 } else if i > 1 {
368 if x >= 11 {
369 let suffix = large(i - 1, cl)?;
370 let last = last_index(&words)?;
371 words[last].push(' ');
372 words[last].push_str(suffix);
373 } else if n1 == 0 {
374 words.push(format!("{} {}", TENS0[1 + 2 * cl], large(i - 1, cl)?));
375 } else if n1 == 1 {
376 words.push(large(i - 1, 0)?.to_string());
377 } else {
378 // `x == 2` <=> `n1 == 2` here (x < 11 and n1 >= 2 forces x == n1).
379 let idx = 1 + 2 * usize::from(construct_last || x == 2);
380 words.push(format!("{} {}", ONES[n1 as usize][idx], large(i - 1, cl)?));
381 }
382 }
383
384 Ok(words)
385}
386
387/// Python's module-level `int2word`.
388///
389/// The three `assert`s at the top of the Python function
390/// (`n == int(n)`, `not construct or not ordinal`,
391/// `ordinal or (not definite and not plural)`) are all satisfied by every
392/// in-scope call site, so they are documented rather than modelled.
393fn int2word(
394 n: &BigInt,
395 gender_m: bool,
396 construct: bool,
397 ordinal: bool,
398 definite: bool,
399 plural: bool,
400) -> Result<String> {
401 if n >= maxval_ref() {
402 return Err(N2WError::Overflow(format!(
403 "abs({}) must be less than {}.",
404 n,
405 maxval_ref()
406 )));
407 }
408
409 if n.is_zero() {
410 // Quirk (3): the ordinal of zero is "האפס", not an error.
411 return Ok(if ordinal {
412 format!("{}{}", DEF, ZERO)
413 } else {
414 ZERO.to_string()
415 });
416 }
417
418 let mut words: Vec<String> = Vec::new();
419 let chunks = splitbyx3(&n.to_string());
420 let mut i = chunks.len();
421 for x in chunks {
422 i -= 1;
423
424 if x == 0 {
425 continue;
426 }
427
428 words.extend(chunk2word(n, i, x, gender_m, construct, ordinal, plural)?);
429
430 // The AND conjunction is applied once per non-zero chunk, to whatever
431 // is currently last — not once at the very end.
432 if words.len() > 1 {
433 let last = last_index(&words)?;
434 words[last].insert_str(0, AND);
435 }
436 }
437
438 if ordinal && (n >= &BigInt::from(11) || definite) {
439 if words.is_empty() {
440 return Err(N2WError::Index("list index out of range".into()));
441 }
442 words[0].insert_str(0, DEF);
443 }
444
445 Ok(words.join(" "))
446}
447
448/// Python `str(float)`, for `verify_ordinal`'s error messages only.
449///
450/// The corpora record only the exception *type*; the message just has to
451/// match `errmsg_floatord`/`errmsg_negord`'s `%s` formatting closely.
452/// Python repr rules: whole values keep a trailing ".0" ("-1000000.0",
453/// "-0.0"); |v| >= 1e16 or 0 < |v| < 1e-4 switch to exponent form with an
454/// explicit sign and >= 2 exponent digits ("1e+16", "1e-05").
455fn py_float_str(f: f64) -> String {
456 if f.is_nan() {
457 return "nan".to_string();
458 }
459 if f.is_infinite() {
460 return if f > 0.0 { "inf" } else { "-inf" }.to_string();
461 }
462 let a = f.abs();
463 if a != 0.0 && !(1e-4..1e16).contains(&a) {
464 // Rust's `{:e}` is shortest-round-trip like Python's repr, but writes
465 // "1e16" where Python writes "1e+16" (signed, zero-padded to 2).
466 let s = format!("{:e}", f);
467 if let Some(pos) = s.find('e') {
468 let exp: i32 = s[pos + 1..].parse().unwrap_or(0);
469 let sign = if exp < 0 { '-' } else { '+' };
470 return format!("{}e{}{:02}", &s[..pos], sign, exp.abs());
471 }
472 s
473 } else if f.fract() == 0.0 {
474 // `{:.1}` keeps the ".0" and the sign of -0.0.
475 format!("{:.1}", f)
476 } else {
477 format!("{}", f)
478 }
479}
480
481/// Python `str(value)` of a float-or-Decimal input, for error messages.
482fn float_value_str(v: &crate::floatpath::FloatValue) -> String {
483 match v {
484 FloatValue::Float { value, .. } => py_float_str(*value),
485 FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
486 }
487}
488
489/// `gender` kwarg → Python's `gender == "m"`.
490///
491/// Absent means the signature default (`default_m`); a present value of any
492/// other shape — "f", "x", `None`, a non-string — compares unequal to "m"
493/// and selects the feminine forms. See the module docs.
494fn kw_gender_m(kw: &Kwargs, default_m: bool) -> bool {
495 match kw.get("gender") {
496 None => default_m,
497 Some(KwVal::Str(s)) => s == "m",
498 Some(_) => false,
499 }
500}
501
502/// A bool-only kwarg (`construct`/`definite`/`plural`): absent → `false`,
503/// a real bool → itself, anything else → `None` (fall back to Python, which
504/// reproduces the arithmetic-on-non-bool behaviour — see the module docs).
505fn kw_flag(kw: &Kwargs, key: &str) -> Option<bool> {
506 match kw.get(key) {
507 None => Some(false),
508 Some(KwVal::Bool(b)) => Some(*b),
509 Some(_) => None,
510 }
511}
512
513/// `MAXVAL = int("1" + "0" * 66)` == 10**66, installed by `setup()`.
514fn maxval_ref() -> &'static BigInt {
515 static MAXVAL: OnceLock<BigInt> = OnceLock::new();
516 MAXVAL.get_or_init(|| BigInt::from(10u32).pow(66))
517}
518
519/// `Num2Word_HE.CURRENCY_FORMS`, verbatim.
520///
521/// HE's own class attribute, shadowing `Num2Word_Base`'s empty dict. Confirmed
522/// against the live `CONVERTER_CLASSES["he"].CURRENCY_FORMS`: exactly these
523/// three codes, no EN/EUR leakage.
524fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
525 const CENTS: [&str; 2] = ["סנט", "סנטים"];
526 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
527 m.insert(
528 "ILS",
529 CurrencyForms::new(&["שקל", "שקלים"], &["אגורה", "אגורות"]),
530 );
531 m.insert("EUR", CurrencyForms::new(&["אירו", "אירו"], &CENTS));
532 m.insert("USD", CurrencyForms::new(&["דולר", "דולרים"], &CENTS));
533 m
534}
535
536/// `Num2Word_HE.to_currency`'s `zero_patterns` list, verbatim and in order
537/// (including the duplicated "нула стотинки").
538///
539/// Copied wholesale from some upstream multi-language sweep: only
540/// "אפס אגורות" — ILS's zero cents — can ever occur in a Hebrew result, since
541/// every other piece of `result` comes from HE's own Hebrew wordlists. The
542/// rest are dead weight, kept so the loop matches Python element for element.
543const ZERO_PATTERNS: [&str; 21] = [
544 "zero cent",
545 "nul cent",
546 "null cent",
547 "sıfır kuruş",
548 "אפס אגורות",
549 "zero sen",
550 "ศูนย์สตางค์",
551 "không xu",
552 "शून्य पैसे",
553 "শূন্য পয়সা",
554 "nula lipa",
555 "нула пара",
556 "ноль копеек",
557 "нула стотинки",
558 "零分",
559 "ዜሮ ሳንቲም",
560 "صفر",
561 "sero sent",
562 "dim ceiniog",
563 "ნულოვანი თეთრი",
564 "нула стотинки",
565];
566
567/// The alternation inside `to_currency`'s connecting-word regex, verbatim and
568/// in order (duplicates and all — "და" appears four times).
569///
570/// Note Hebrew's own conjunction "ו" (U+05D5) is *not* in the list; the Arabic
571/// "و" (U+0648) that is there only looks like it. That omission is why the
572/// default `separator="ו"` survives the scrub and leaves a dangling "ו" — see
573/// [`strip_zero_cents`].
574const CONJUNCTIONS: [&str; 24] = [
575 "and", "və", "և", "და", "ir", "და", "და", "و", "و", "与", "ja", "और", "এবং", "i", "и", "и",
576 "と", "그리고", "และ", "và", "dan", "a", "e", "და",
577];
578
579/// Python's *simple* case fold for one `char`.
580///
581/// `char::to_lowercase` can expand one char into several ("İ" → "i̇"), which
582/// Python's `re` does not do when matching a literal under `re.IGNORECASE`. An
583/// expansion is therefore treated as "no simple lowercase" and the char is
584/// compared as-is, which keeps the comparison 1:1 and — crucially — keeps
585/// haystack byte offsets valid.
586fn simple_lower(c: char) -> char {
587 let mut it = c.to_lowercase();
588 match (it.next(), it.next()) {
589 (Some(l), None) => l,
590 _ => c,
591 }
592}
593
594/// Case-insensitive literal match of `needle` at byte offset `at`.
595/// Returns the number of *haystack* bytes consumed, or `None`.
596fn match_ci_at(hay: &str, at: usize, needle: &str) -> Option<usize> {
597 let mut h = hay[at..].chars();
598 let mut consumed = 0usize;
599 for nc in needle.chars() {
600 let hc = h.next()?;
601 if simple_lower(hc) != simple_lower(nc) {
602 return None;
603 }
604 consumed += hc.len_utf8();
605 }
606 Some(consumed)
607}
608
609/// Leftmost case-insensitive occurrence of `needle` at or after `from`, as
610/// `(start, end)` byte offsets.
611fn find_ci(hay: &str, needle: &str, from: usize) -> Option<(usize, usize)> {
612 let mut i = from;
613 while i <= hay.len() {
614 if hay.is_char_boundary(i) {
615 if let Some(len) = match_ci_at(hay, i, needle) {
616 return Some((i, i + len));
617 }
618 }
619 i += 1;
620 }
621 None
622}
623
624/// Greedy `\s+` at `at`: the offset just past the run, or `None` if there is
625/// no whitespace there at all.
626fn skip_ws1(hay: &str, at: usize) -> Option<usize> {
627 let mut i = at;
628 for c in hay[at..].chars() {
629 if !c.is_whitespace() {
630 break;
631 }
632 i += c.len_utf8();
633 }
634 if i > at {
635 Some(i)
636 } else {
637 None
638 }
639}
640
641/// `re.sub(re.escape(needle), "", hay, flags=re.IGNORECASE)`.
642fn remove_all_ci(hay: &str, needle: &str) -> String {
643 if needle.is_empty() {
644 return hay.to_string();
645 }
646 let mut out = String::with_capacity(hay.len());
647 let mut i = 0usize;
648 while let Some((s, e)) = find_ci(hay, needle, i) {
649 out.push_str(&hay[i..s]);
650 i = e;
651 }
652 out.push_str(&hay[i..]);
653 out
654}
655
656/// One match of `\s+(<conj>)\s+<pattern>` starting at `at`; returns its end.
657///
658/// Both `\s+` are greedy and need no backtracking: no conjunction and no zero
659/// pattern begins with whitespace, so the maximal run is always the right one.
660/// Alternatives are tried in Python's source order, and a conjunction that
661/// matches but is not followed by `\s+<pattern>` falls through to the next —
662/// which is what `re`'s backtracking does.
663fn match_conjoined(hay: &str, at: usize, pattern: &str) -> Option<usize> {
664 let a = skip_ws1(hay, at)?;
665 for conj in CONJUNCTIONS {
666 let Some(clen) = match_ci_at(hay, a, conj) else {
667 continue;
668 };
669 let Some(b) = skip_ws1(hay, a + clen) else {
670 continue;
671 };
672 if let Some(plen) = match_ci_at(hay, b, pattern) {
673 return Some(b + plen);
674 }
675 }
676 None
677}
678
679/// `re.sub(r"\s+(<conj>)\s+" + re.escape(pattern), "", hay, flags=re.IGNORECASE)`.
680fn remove_conjoined(hay: &str, pattern: &str) -> String {
681 let mut out = String::with_capacity(hay.len());
682 let mut i = 0usize;
683 let mut cut = 0usize;
684 while i < hay.len() {
685 if !hay.is_char_boundary(i) {
686 i += 1;
687 continue;
688 }
689 // `match_conjoined` needs >= 1 whitespace char, so `end > i` always
690 // and this cannot spin.
691 if let Some(end) = match_conjoined(hay, i, pattern) {
692 out.push_str(&hay[cut..i]);
693 cut = end;
694 i = end;
695 continue;
696 }
697 i += 1;
698 }
699 out.push_str(&hay[cut..]);
700 out
701}
702
703/// The zero-cents scrub `Num2Word_HE.to_currency` runs over the **int** result.
704///
705/// ```python
706/// for pattern in zero_patterns:
707/// if pattern in result.lower():
708/// result = re.sub(r"\s+(and|...)\s+" + re.escape(pattern), "", result, flags=re.IGNORECASE)
709/// result = re.sub(re.escape(pattern), "", result, flags=re.IGNORECASE)
710/// result = " ".join(result.split())
711/// return result.strip()
712/// ```
713///
714/// The whitespace normalisation sits *inside* the `if`, so it only runs for a
715/// pattern that actually hit; `.strip()` always runs. Both are reproduced.
716///
717/// **Python bug, reproduced**: for ILS the conjunction sub never fires, because
718/// HE's default separator "ו" is glued to the preceding word ("שקליםו") and is
719/// not in `CONJUNCTIONS` anyway. So only the literal sub runs and the separator
720/// is orphaned: `to_currency(100, "ILS")` == "מאה שקליםו" — "a hundred shekels
721/// and". Verified against the live interpreter.
722///
723/// The containment guard uses `find_ci` (simple per-char fold) where Python
724/// uses `str.lower()` (full fold). They can only disagree on chars whose
725/// lowercase expands, none of which occur in `ZERO_PATTERNS` or in any string
726/// HE can build.
727fn strip_zero_cents(result: &str) -> String {
728 let mut result = result.to_string();
729 for pattern in ZERO_PATTERNS {
730 if find_ci(&result, pattern, 0).is_some() {
731 result = remove_conjoined(&result, pattern);
732 result = remove_all_ci(&result, pattern);
733 result = result.split_whitespace().collect::<Vec<_>>().join(" ");
734 }
735 }
736 result.trim().to_string()
737}
738
739pub struct LangHe {
740 /// Python's `Num2Word_HE.__init__(self, makaf="-")`. Assigned there and
741 /// read by nothing in the whole package (`grep -rn makaf num2words2/`
742 /// finds only the two lines of `__init__`), currency included. Stored for
743 /// parity with the Python constructor and otherwise unused.
744 #[allow(dead_code)]
745 makaf: String,
746 /// Built once, here. `to_currency`/`to_cheque` only ever read this table,
747 /// and rebuilding it per call is what made an earlier revision of the port
748 /// slower than the Python it replaces.
749 currency_forms: HashMap<&'static str, CurrencyForms>,
750}
751
752impl Default for LangHe {
753 fn default() -> Self {
754 Self::new()
755 }
756}
757
758impl LangHe {
759 pub fn new() -> Self {
760 LangHe {
761 makaf: "-".to_string(),
762 currency_forms: build_currency_forms(),
763 }
764 }
765}
766
767impl Lang for LangHe {
768 /// This language's own `to_currency(currency=...)` default,
769 /// read from the live Python signature. Only 44 of 156 use EUR.
770 fn default_currency(&self) -> &str {
771 "ILS"
772 }
773
774 /// This language's own `to_currency(separator=...)` default,
775 /// read from the live Python signature. Base's is ",", but only
776 /// 36 of 149 languages actually use it — most default to " " or a
777 /// conjunction, so inheriting Base's comma silently corrupts them.
778 fn default_separator(&self) -> &str {
779 "ו"
780 }
781
782 fn maxval(&self) -> &BigInt {
783 maxval_ref()
784 }
785
786 fn negword(&self) -> &str {
787 NEGWORD
788 }
789
790 fn pointword(&self) -> &str {
791 "נקודה"
792 }
793
794 /// `Num2Word_HE.to_cardinal(value, gender="f", construct=False)`.
795 ///
796 /// Note this override does *not* call `self.title()` (the base's version
797 /// does); `is_title` is False for Hebrew either way.
798 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
799 let mut out = String::new();
800 let mut v = value.clone();
801 if v.is_negative() {
802 v = v.abs();
803 out = format!("{} ", NEGWORD.trim());
804 }
805
806 if &v >= self.maxval() {
807 return Err(N2WError::Overflow(format!(
808 "abs({}) must be less than {}.",
809 v,
810 self.maxval()
811 )));
812 }
813
814 Ok(format!(
815 "{}{}",
816 out,
817 int2word(&v, false, false, false, false, false)?
818 ))
819 }
820
821 /// `Num2Word_HE.to_ordinal(value, gender="m", definite=False, plural=False)`.
822 ///
823 /// The `verify_ordinal` call is inlined: its float branch cannot fire on a
824 /// `BigInt`, and its negative branch raises `TypeError` with
825 /// `errmsg_negord`.
826 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
827 if value.is_negative() {
828 return Err(N2WError::Type(format!(
829 "Cannot treat negative num {} as ordinal.",
830 value
831 )));
832 }
833
834 if value >= self.maxval() {
835 return Err(N2WError::Overflow(format!(
836 "abs({}) must be less than {}.",
837 value,
838 self.maxval()
839 )));
840 }
841
842 int2word(value, true, false, true, false, false)
843 }
844
845 /// `Num2Word_HE.to_cardinal_float(value, gender="f")`.
846 ///
847 /// HE overrides `to_cardinal_float` and, unlike `Num2Word_Base`, **always
848 /// float-casts** (`float_value = float(value)`) before `float2tuple`. Two
849 /// consequences, both load-bearing and both verified against the live
850 /// interpreter:
851 ///
852 /// 1. **Decimal input is routed through the *float* branch**, so it picks
853 /// up the binary-f64 rounding that base.py's #603 fix deliberately
854 /// avoids. `Decimal("98746251323029.99")` → `float` `…029.98` →
855 /// "…עשרים ותשע נקודה תשע שמונה" (…29 point nine **eight**), and
856 /// `Decimal("1.10")` → `float` `1.1` → precision 1 → "אחת נקודה אחת"
857 /// (one point one), *not* the exact-Decimal "…אחת אפס". The inherited
858 /// `Num2Word_Base.to_cardinal_float` (the trait default) would keep the
859 /// Decimal exact and get both wrong — that is why HE overrides here.
860 /// 2. **`precision=` is ignored.** `float2tuple` recomputes
861 /// `self.precision = abs(Decimal(str(float_value)).as_tuple().exponent)`,
862 /// clobbering the override num2words installs on `self.precision`. Live:
863 /// `num2words(1.5, lang="he", precision=4)` == "אחת נקודה חמש" (precision
864 /// 1). So `precision_override` is deliberately dropped.
865 ///
866 /// For *float* input HE's method is byte-identical to base's (`gender="f"`
867 /// is the default and `to_cardinal(pre)` already renders feminine), so the
868 /// shared `default_to_cardinal_float` is reused directly. For *Decimal*
869 /// input the float-cast + repr-precision is exactly what
870 /// `cardinal_from_bigdecimal` performs.
871 fn to_cardinal_float(
872 &self,
873 value: &FloatValue,
874 _precision_override: Option<u32>,
875 ) -> Result<String> {
876 match value {
877 FloatValue::Float { .. } => {
878 crate::floatpath::default_to_cardinal_float(self, value, None)
879 }
880 FloatValue::Decimal { value: d, .. } => {
881 crate::floatpath::cardinal_from_bigdecimal(self, d)
882 }
883 }
884 }
885
886 /// `to_ordinal(float/Decimal)` — `Num2Word_HE.to_ordinal` starts with
887 /// `Num2Word_Base.verify_ordinal`, so the float path is *not* routed like
888 /// the cardinal (the trait default): a non-whole value raises `TypeError`
889 /// (`errmsg_floatord`), a negative whole value raises `TypeError`
890 /// (`errmsg_negord`), and a non-negative whole value takes the ordinal
891 /// int path — `to_ordinal(5.0)` == "חמישי", `to_ordinal(11.0)` ==
892 /// "האחד עשר".
893 ///
894 /// Ordering quirks reproduced from `verify_ordinal`:
895 /// * The float check (`value == int(value)`) runs first, so `-1.5` gets
896 /// the *floatord* message, not negord.
897 /// * The negative check is `abs(value) == value` — a value comparison,
898 /// so `-0.0` passes (`abs(-0.0) == -0.0` is True) and renders "האפס".
899 /// That is why this keys off the converted int's sign rather than
900 /// `FloatValue::is_negative` (which is sign-bit aware).
901 /// * `int(inf)`/`int(nan)` inside the first check raise
902 /// OverflowError/ValueError before any comparison; modelled up front
903 /// for completeness (the dispatcher keeps non-finite floats on the
904 /// Python side, so this arm is belt-and-braces).
905 ///
906 /// After verify_ordinal, `Num2Word_HE.to_ordinal` checks
907 /// `value >= self.MAXVAL` (OverflowError, `errmsg_toobig` formatted with
908 /// the *original* value) and calls
909 /// `int2word(int(value), gender="m", ordinal=True)`.
910 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
911 if let FloatValue::Float { value: f, .. } = value {
912 if f.is_infinite() {
913 return Err(N2WError::Overflow(
914 "cannot convert float infinity to integer".into(),
915 ));
916 }
917 if f.is_nan() {
918 return Err(N2WError::Value(
919 "cannot convert float NaN to integer".into(),
920 ));
921 }
922 }
923 let Some(n) = value.as_whole_int() else {
924 return Err(N2WError::Type(format!(
925 "Cannot treat float {} as ordinal.",
926 float_value_str(value)
927 )));
928 };
929 if n.is_negative() {
930 return Err(N2WError::Type(format!(
931 "Cannot treat negative num {} as ordinal.",
932 float_value_str(value)
933 )));
934 }
935 if &n >= self.maxval() {
936 return Err(N2WError::Overflow(format!(
937 "abs({}) must be less than {}.",
938 float_value_str(value),
939 self.maxval()
940 )));
941 }
942 int2word(&n, true, false, true, false, false)
943 }
944
945 // to_ordinal_num: inherited from Num2Word_Base (returns the value
946 // unchanged, no verify_ordinal) → trait default `Ok(value.to_string())`.
947 // to_year: inherited from Num2Word_Base (delegates to to_cardinal) → trait
948 // default, which routes through the to_cardinal override above.
949
950 // ---- grammatical kwargs ----------------------------------------------
951
952 /// `to_cardinal(value, gender="f", construct=False)` with kwargs.
953 ///
954 /// Same body as [`LangHe::to_cardinal`], with `gender`/`construct`
955 /// forwarded into `int2word`. Corpus-verified quirks: `construct=True`
956 /// only changes the units chunk when the *whole* number is below 11
957 /// (`cop`'s `n < 11` factor) or exactly 100 ("מאת"), so
958 /// `to_cardinal(1234, construct=True)` equals the plain cardinal.
959 fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
960 if !kw.only(&["gender", "construct"]) {
961 return Err(N2WError::Fallback("kwargs".into()));
962 }
963 let gender_m = kw_gender_m(kw, false);
964 let Some(construct) = kw_flag(kw, "construct") else {
965 return Err(N2WError::Fallback("kwargs".into()));
966 };
967
968 let mut out = String::new();
969 let mut v = value.clone();
970 if v.is_negative() {
971 v = v.abs();
972 out = format!("{} ", NEGWORD.trim());
973 }
974
975 if &v >= self.maxval() {
976 return Err(N2WError::Overflow(format!(
977 "abs({}) must be less than {}.",
978 v,
979 self.maxval()
980 )));
981 }
982
983 Ok(format!(
984 "{}{}",
985 out,
986 int2word(&v, gender_m, construct, false, false, false)?
987 ))
988 }
989
990 /// `to_ordinal(value, gender="m", definite=False, plural=False)` with
991 /// kwargs.
992 ///
993 /// `definite=True` forces the "ה" prefix below 11 (11 and up already get
994 /// it); `plural=True` selects the plural ordinal forms, but only for
995 /// n in 1..=10 (`cop`'s `n < 11` factor) — `to_ordinal(11, plural=True)`
996 /// is still "האחד עשר". Negatives raise `TypeError` via `verify_ordinal`,
997 /// exactly as in the plain [`LangHe::to_ordinal`].
998 fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
999 if !kw.only(&["gender", "definite", "plural"]) {
1000 return Err(N2WError::Fallback("kwargs".into()));
1001 }
1002 let gender_m = kw_gender_m(kw, true);
1003 let (Some(definite), Some(plural)) = (kw_flag(kw, "definite"), kw_flag(kw, "plural"))
1004 else {
1005 return Err(N2WError::Fallback("kwargs".into()));
1006 };
1007
1008 if value.is_negative() {
1009 return Err(N2WError::Type(format!(
1010 "Cannot treat negative num {} as ordinal.",
1011 value
1012 )));
1013 }
1014
1015 if value >= self.maxval() {
1016 return Err(N2WError::Overflow(format!(
1017 "abs({}) must be less than {}.",
1018 value,
1019 self.maxval()
1020 )));
1021 }
1022
1023 int2word(value, gender_m, false, true, definite, plural)
1024 }
1025
1026 /// `to_currency(..., prefer_singular=False, prefer_singular_cents=False)`
1027 /// with kwargs.
1028 ///
1029 /// Both kwargs are accepted by `Num2Word_HE.to_currency` and then never
1030 /// read — the body forwards neither to `super().to_currency` nor to
1031 /// `pluralize` (whose `prefer_singular` limb is unreachable, see
1032 /// [`LangHe::pluralize`]). Any value, of any type, is a no-op in Python,
1033 /// so this delegates unconditionally to the plain `to_currency`.
1034 fn to_currency_kw(
1035 &self,
1036 val: &CurrencyValue,
1037 currency: &str,
1038 cents: bool,
1039 separator: Option<&str>,
1040 adjective: bool,
1041 kw: &Kwargs,
1042 ) -> Result<String> {
1043 if !kw.only(&["prefer_singular", "prefer_singular_cents"]) {
1044 return Err(N2WError::Fallback("kwargs".into()));
1045 }
1046 self.to_currency(val, currency, cents, separator, adjective)
1047 }
1048
1049 // ---- currency -------------------------------------------------------
1050 //
1051 // HE overrides only `CURRENCY_FORMS`, `pluralize` and `to_currency`.
1052 // `_money_verbose`, `_cents_verbose`, `_cents_terse` and `to_cheque` come
1053 // straight from `Num2Word_Base`, and `CURRENCY_ADJECTIVES` /
1054 // `CURRENCY_PRECISION` are both empty — the trait defaults already mirror
1055 // all of that, so they are deliberately not overridden here.
1056
1057 fn lang_name(&self) -> &str {
1058 "Num2Word_HE"
1059 }
1060
1061 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1062 self.currency_forms.get(code)
1063 }
1064
1065 /// `Num2Word_HE.pluralize(n, forms, currency=None, prefer_singular=False)`.
1066 ///
1067 /// ```python
1068 /// form = 1
1069 /// if n == 1 or prefer_singular and (abs(n) >= 11 or n == 0 or currency != "ILS"):
1070 /// form = 0
1071 /// return forms[form]
1072 /// ```
1073 ///
1074 /// Every call reaching here comes from `Num2Word_Base.to_currency` /
1075 /// `to_cheque`, which pass `self.pluralize(n, forms)` positionally — so
1076 /// `currency` stays `None` and `prefer_singular` stays `False`. `False and
1077 /// (...)` short-circuits, collapsing the rule to `forms[0 if n == 1 else 1]`
1078 /// and making the whole `currency`/`prefer_singular` limb unreachable. HE's
1079 /// own `to_currency` accepts `prefer_singular`/`prefer_singular_cents`
1080 /// kwargs but never forwards them, so they are dead too — that is why the
1081 /// trait's two-argument `pluralize` is a faithful signature here.
1082 ///
1083 /// Python indexes the tuple directly, so a one-form entry with `n != 1`
1084 /// would raise `IndexError`. All three HE entries have two forms, so this
1085 /// is unreachable; mapped to `Index` rather than panicking anyway.
1086 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1087 let form = if n.is_one() { 0 } else { 1 };
1088 forms
1089 .get(form)
1090 .cloned()
1091 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1092 }
1093
1094 /// `Num2Word_HE.to_currency`.
1095 ///
1096 /// Two paths, and the split is load-bearing:
1097 ///
1098 /// * **float/Decimal** → straight through to `Num2Word_Base.to_currency`.
1099 /// * **int** → `super().to_currency(float(val), ...)`, i.e. cast to a float
1100 /// *first*. Base's `isinstance(val, int)` early-out — the one that skips
1101 /// the cents segment — therefore never fires for HE, and `has_decimal` is
1102 /// `True` via `isinstance(val, float)`. The int renders *with* cents, and
1103 /// the redundant zero-cents text is then scrubbed by
1104 /// [`strip_zero_cents`].
1105 ///
1106 /// The asymmetry is observable: `to_currency(1, "ILS")` == "אחת שקלו"
1107 /// (scrubbed) but `to_currency(1.0, "ILS")` == "אחת שקלו אפס אגורות"
1108 /// (not scrubbed — floats never reach the scrub).
1109 fn to_currency(
1110 &self,
1111 val: &CurrencyValue,
1112 currency: &str,
1113 cents: bool,
1114 separator: Option<&str>,
1115 adjective: bool,
1116 ) -> Result<String> {
1117 // The trait hands us `None` when the caller omitted `separator=`; the
1118 // resolution normally done by the default body has to happen here
1119 // because we are replacing that body. `default_separator` is already
1120 // generated from HE's live signature (`separator=AND`, i.e. "ו").
1121 let separator = separator.unwrap_or(self.default_separator());
1122
1123 let CurrencyValue::Int(v) = val else {
1124 // `return super().to_currency(val, ...)` — no scrub for floats.
1125 return crate::currency::default_to_currency(
1126 self, val, currency, cents, separator, adjective,
1127 );
1128 };
1129
1130 // Python's `float(val)`, modelled as an *exact* widening.
1131 //
1132 // Faithful for every int f64 represents exactly (|v| <= 2**53), which
1133 // is every corpus row and every plausible money amount. Beyond that
1134 // the cast is lossy in Python and this deliberately is not, so three
1135 // divergences remain — all measured against the live interpreter, none
1136 // fixable from this file:
1137 //
1138 // * |v| > 2**53: Python rounds through the f64. `to_currency(2**53+1)`
1139 // says "...תשעים ושתיים" (…992); this says …993. Reproducing it
1140 // needs f64 rounding *plus* Python's `repr` shortest-round-trip (it
1141 // is `Decimal(str(float(v)))`, not the exact binary value), which is
1142 // the second formatter PORTING_CURRENCY.md exists to avoid.
1143 // * |v| >= 10**26: Python raises `decimal.InvalidOperation` —
1144 // `quantize` needs digits(v)+2 > the default 28-digit context. Not
1145 // an HE bug: `currency.rs`'s `round_half_up` models no context
1146 // limit, so every language already diverges here on the *float*
1147 // path (`en` raises InvalidOperation for 1e26 too). HE is only
1148 // unusual in reaching it from an *int*, because of this cast — `en`
1149 // renders 10**26 fine via Base's int early-out. No `N2WError`
1150 // variant fits a stdlib `decimal.InvalidOperation`, and the fix
1151 // belongs in `currency.rs`, not here.
1152 // * |v| >= ~1.8e308: Python's `float()` itself raises
1153 // OverflowError("int too large to convert to float"). Masked in
1154 // practice — MAXVAL is 10**66, so `to_cardinal` already raises
1155 // OverflowError (different message, same type) long before.
1156 let as_float = CurrencyValue::Decimal {
1157 value: BigDecimal::from(v.clone()),
1158 has_decimal: true,
1159 is_float: true,
1160 };
1161 let result = crate::currency::default_to_currency(
1162 self, &as_float, currency, cents, separator, adjective,
1163 )?;
1164 Ok(strip_zero_cents(&result))
1165 }
1166}