Skip to main content

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}