Skip to main content

num2words2_core/
lang_nn.rs

1//! Port of `lang_NN.py` (Norwegian Nynorsk).
2//!
3//! Shape: **self-contained**. `Num2Word_NN` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the guard in
5//! `Num2Word_Base.__init__` never fires: `self.cards` is never built and
6//! `self.MAXVAL` is never assigned. `to_cardinal` is overridden outright and
7//! drives a private `_int_to_word` recursion. Consequently `cards`/`maxval`/
8//! `merge` stay at their trait defaults here, and there is **no overflow
9//! check** — arbitrarily large input is accepted and silently degraded (see
10//! bug 5 below). No integer input can make this language raise.
11//!
12//! `Num2Word_NN` overrides all four in-scope methods (`to_cardinal`,
13//! `to_ordinal`, `to_ordinal_num`, `to_year`), so nothing is inherited from
14//! `Num2Word_Base` for the ported surface. In particular `title()` is never
15//! reached: base's `to_cardinal` applies it, but NN's override does not, and
16//! `is_title` is `False` anyway.
17//!
18//! `setup()` also sets `pointword = "point"`, used only by the float path
19//! (`"." in n`). `Num2Word_NN` overrides `to_cardinal` outright and handles
20//! non-integer input inline off `str(number)`, so it never reaches
21//! `base.float2tuple`/`to_cardinal_float`; [`LangNn::to_cardinal_float`] ports
22//! that inline branch directly. It is surfaced via the `pointword()` trait
23//! method for the same reason.
24//!
25//! Because everything keys on `str(number)`, a float/Decimal takes the string
26//! route for *every* value, whole ones included: `to_cardinal(5.0)` is
27//! "fem point zero", never "fem" — Base's `int(value) == value` whole-value
28//! shortcut does not exist here. [`LangNn::cardinal_float_entry`] pins that
29//! full routing (and `year_float_entry` inherits it, matching `to_year` ==
30//! `to_cardinal`); [`LangNn::ordinal_float_entry`] rides the same path and
31//! appends "-de" (`to_ordinal(5.0)` == "fem point zero-de");
32//! [`LangNn::ordinal_num_float_entry`] is `str(number) + "."`, so "5.0." and
33//! even "1e+16." are real outputs. And because the route is the string form,
34//! a value whose `str()` is *exponent notation* crashes `int()` — a Python
35//! bug reproduced faithfully (see bug 9 below).
36//!
37//! # Faithfully reproduced Python bugs
38//!
39//! This is a port, not a rewrite. Every item below looks wrong for Norwegian
40//! but is exactly what Python emits, and each is pinned by the frozen corpus:
41//!
42//! 1. **Zero is English.** `_int_to_word` opens with
43//!    `return self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is `""` —
44//!    always falsy — so the conditional is dead and every zero renders as the
45//!    English "zero", never Nynorsk "null". Corpus: `0 -> "zero"`,
46//!    `to_ordinal(0) -> "zero-de"`.
47//! 2. **No teens.** The `< 100` branch is a plain tens/ones split with no
48//!    11..19 special case, so 11 is "ti ein" (literally "ten one") instead of
49//!    "elleve", 12 is "ti to", 19 is "ti ni". This propagates upward:
50//!    `12345 -> "ti to tusen tre hundre førti fem"`.
51//! 3. **No "hundre"/"tusen" elision and no compounding.** Norwegian writes
52//!    "hundre" for 100 and compounds ("tjueein"); this module always prefixes
53//!    the multiplier and space-separates every token, so `100 -> "ein hundre"`
54//!    and `1000 -> "ein tusen"`.
55//! 4. **"million" never pluralises.** The `< 10^9` branch appends the bare
56//!    singular, so `10^7 -> "ti million"`, not "ti millionar".
57//! 5. **Everything >= 10^9 degrades to digits.** The final `else` is
58//!    `return str(number)  # Fallback for very large numbers` — no billion
59//!    word exists. So `10^9 -> "1000000000"` and, composed with the ordinal
60//!    suffix, `to_ordinal(10**9) -> "1000000000-de"`. This is the one place
61//!    where BigInt is load-bearing: the corpus reaches 10^21, well past u64,
62//!    and the value must round-trip as decimal digits. See [`int_to_word`].
63//! 6. **The ordinal is cardinal + "-de", unconditionally.** No stem changes,
64//!    no agreement, and the suffix binds to the last token only, so
65//!    `to_ordinal(1234567)` ends "...seksti sju-de". Negatives are *not*
66//!    rejected (base's `errmsg_negord` guard is bypassed by the override):
67//!    `to_ordinal(-1) -> "minus ein-de"`.
68//! 7. **An unknown currency code silently becomes kroner.** `to_currency`
69//!    looks the code up with `CURRENCY_FORMS.get(currency, <first value>)`
70//!    instead of indexing, so anything outside NOK/USD/EUR renders with NOK's
71//!    forms rather than raising: `to_currency(12.34, "JPY")` is
72//!    "ti to kroner tretti fire øre". The inherited `to_cheque` indexes the
73//!    same dict and *does* raise for those codes, so the two entry points
74//!    disagree about which currencies exist. Both halves are corpus-pinned.
75//! 8. **Currency precision is hardcoded to two decimals.** `to_currency`
76//!    never reads `CURRENCY_PRECISION` (which is `{}` anyway), so the
77//!    zero-decimal and three-decimal currencies are wrong in both directions:
78//!    JPY grows a subunit and KWD/BHD are quantised to /100, not /1000.
79//! 9. **Values whose `str()` uses exponent notation raise ValueError.** A
80//!    float at or above 1e16 reprs as `'1e+16'`; a Decimal whose exponent is
81//!    positive prints as `'1E+2'`/`'1E+20'` (and one whose adjusted exponent
82//!    drops below -6 as `'1E-7'`). None of those contain a "." — the whole
83//!    token hits `int()` and dies: `ValueError: invalid literal for int()
84//!    with base 10: '1e+16'`. Ones that *do* keep a "." (`'1.5e-05'`) split
85//!    and then die in the digit loop on the exponent marker (`int('e')`,
86//!    uppercase `int('E')` for Decimals). Corpus-pinned for 1e+16, 1e+20,
87//!    1E+2 and 1E+20 across cardinal/ordinal/year. `to_ordinal_num` never
88//!    calls `int()`, so it *succeeds* on the same inputs ("1e+16.").
89//! 10. **`"Infinity"`/`"NaN"` strings parse but blow up later.** Base's
90//!    `str_to_number` (`Decimal(value)`) accepts them; the failure is NN's
91//!    `int("Infinity")` inside `to_cardinal` (ValueError; "-Infinity" loses
92//!    its sign first, so the message always quotes 'Infinity') — while
93//!    `to_ordinal_num("Infinity")` is *answered*, with "Infinity.". The Rust
94//!    dispatcher hard-codes Base semantics for non-finite parses
95//!    (OverflowError), which NN never executes, so [`LangNn::str_to_number`]
96//!    punts them back to the pure-Python original via NotImplemented; the
97//!    fallback then raises (or returns) the byte-exact original per mode.
98//!
99//! # Dead code reproduced
100//!
101//! `_int_to_word`'s `number < 0` branch (`negword + _int_to_word(abs(n))`) is
102//! unreachable: `to_cardinal` strips the sign textually before calling it, and
103//! every recursive call passes a positive value. It is mirrored in
104//! [`int_to_word`] to keep the structure aligned with the source, but it can
105//! never fire. Note it would double the negword if it ever did.
106
107use crate::base::{Lang, N2WError, Result};
108use crate::currency::{CurrencyForms, CurrencyValue};
109use crate::floatpath::FloatValue;
110use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
111use num_bigint::BigInt;
112use num_traits::{One, Signed, ToPrimitive, Zero};
113use std::collections::HashMap;
114use std::str::FromStr;
115
116/// `setup()`: note the trailing space — NN overrides base's `"(-) "`.
117const NEGWORD: &str = "minus ";
118const POINTWORD: &str = "point";
119
120/// `self.ones`. Index 0 is `""`; see bug 1 — it is only ever read at index 0
121/// through the dead conditional, and at 1..=9 for real digits.
122const ONES: [&str; 10] = [
123    "", "ein", "to", "tre", "fire", "fem", "seks", "sju", "åtte", "ni",
124];
125
126/// `self.tens`. Index 0 is `""` and unreachable (the branch requires n >= 10).
127const TENS: [&str; 10] = [
128    "", "ti", "tjue", "tretti", "førti", "femti", "seksti", "sytti", "åtti", "nitti",
129];
130
131const HUNDRED: &str = "hundre";
132const THOUSAND: &str = "tusen";
133const MILLION: &str = "million";
134
135/// What `self.ones[0] if self.ones[0] else "zero"` actually evaluates to.
136const ZERO_WORD: &str = "zero";
137
138/// The ceiling of the worded range; at or above it Python returns `str(number)`.
139const FALLBACK_AT: u64 = 1_000_000_000;
140
141/// Python's `_int_to_word`.
142///
143/// Splits the bounded case out into [`int_to_word_small`]: below `FALLBACK_AT`
144/// the value provably fits in a u64, and at or above it Python does not word
145/// the number at all — it just prints the digits, which `BigInt::to_string`
146/// reproduces for any magnitude. So no fixed-width cast is ever applied to a
147/// value that could exceed it: `to_u64()` returning `None` (a value past
148/// u64::MAX) lands in the same digits branch as 10^9 does.
149fn int_to_word(n: &BigInt) -> String {
150    if n.is_zero() {
151        return ZERO_WORD.to_string(); // bug 1
152    }
153    if n.is_negative() {
154        // Unreachable — see "Dead code reproduced" in the module docs.
155        return format!("{}{}", NEGWORD, int_to_word(&n.abs()));
156    }
157    match n.to_u64() {
158        Some(v) if v < FALLBACK_AT => int_to_word_small(v),
159        _ => n.to_string(), // bug 5: "Fallback for very large numbers"
160    }
161}
162
163/// The worded range: `0 < n < 10^9`.
164fn int_to_word_small(n: u64) -> String {
165    debug_assert!(n > 0 && n < FALLBACK_AT);
166    if n < 10 {
167        ONES[n as usize].to_string()
168    } else if n < 100 {
169        // bug 2: no teens table, so 11 -> "ti ein".
170        let (t, o) = ((n / 10) as usize, (n % 10) as usize);
171        if o == 0 {
172            TENS[t].to_string()
173        } else {
174            format!("{} {}", TENS[t], ONES[o])
175        }
176    } else if n < 1_000 {
177        // Python indexes `self.ones[hundreds_val]` directly here rather than
178        // recursing; identical for 1..=9, kept literal for fidelity.
179        scale(ONES[(n / 100) as usize], HUNDRED, n % 100)
180    } else if n < 1_000_000 {
181        scale(&int_to_word_small(n / 1_000), THOUSAND, n % 1_000)
182    } else {
183        scale(&int_to_word_small(n / 1_000_000), MILLION, n % 1_000_000)
184    }
185}
186
187/// The shared `head + " " + word [+ " " + rest]` tail of the three scale
188/// branches. Python guards the remainder with `if remainder:`, so a zero
189/// remainder appends nothing and never recurses into the "zero" case.
190fn scale(head: &str, word: &str, remainder: u64) -> String {
191    let mut out = format!("{} {}", head, word);
192    if remainder != 0 {
193        out.push(' ');
194        out.push_str(&int_to_word_small(remainder));
195    }
196    out
197}
198
199/// `CURRENCY_FORMS`, in Python's insertion order — the order is load-bearing,
200/// see [`FALLBACK_INDEX`].
201///
202/// `Num2Word_NN` declares this dict itself and inherits from `Num2Word_Base`,
203/// **not** `Num2Word_EUR`, so the shared-class-dict mutation that
204/// `Num2Word_EN.__init__` performs on `Num2Word_EUR.CURRENCY_FORMS` never
205/// reaches it. Confirmed against the live interpreter: the runtime table is
206/// exactly these three entries, and `CURRENCY_ADJECTIVES` / `CURRENCY_PRECISION`
207/// are both `{}` — hence no `currency_adjective`/`currency_precision` override
208/// below (precision stays 100 for *every* code, including JPY and KWD).
209///
210/// Note NOK's subunit is `("øre", "øre")`: identical singular and plural, which
211/// is correct Nynorsk and makes the `right != 1` test invisible for that entry.
212const FORMS: [(&str, [&str; 2], [&str; 2]); 3] = [
213    ("NOK", ["krone", "kroner"], ["øre", "øre"]),
214    ("USD", ["dollar", "dollars"], ["cent", "cents"]),
215    ("EUR", ["euro", "euros"], ["cent", "cents"]),
216];
217
218/// `list(self.CURRENCY_FORMS.values())[0]` — the `.get()` default in
219/// `to_currency` (bug 7). Python dicts iterate in insertion order, and NOK is
220/// declared first, so this is always the NOK entry.
221const FALLBACK_INDEX: usize = 0;
222
223/// NN's own `to_currency` signature default (`separator=" "`).
224const DEFAULT_SEPARATOR: &str = " ";
225
226/// `Num2Word_Base.to_currency`'s signature default. The FFI boundary cannot
227/// express per-language default arguments, so the Python shim and the diff
228/// harness both pass this literal to mean "caller did not supply one"; see the
229/// `separator` handling in [`LangNn::to_currency`].
230const BASE_DEFAULT_SEPARATOR: &str = ",";
231
232/// Python's `int(s)` failing on a non-decimal literal.
233fn int_value_error(literal: &str) -> N2WError {
234    N2WError::Value(format!(
235        "invalid literal for int() with base 10: '{}'",
236        literal
237    ))
238}
239
240/// Reconstruct `str(number)` for a `FloatValue::Float`, sign included.
241///
242/// CPython reprs a float in fixed notation only while its decimal exponent is
243/// in `[-4, 16)`; outside that it switches to exponent form (`repr(1e16) ==
244/// '1e+16'`, `repr(1e-05) == '1e-05'`), which NN's `int()` then chokes on
245/// (bug 9). Inside the fixed window, `format!("{:.p}")` with the repr-derived
246/// `precision` reproduces `str()` byte for byte — correctly rounding the
247/// binary value to the repr's own digit count re-derives exactly those digits
248/// (shortest-round-trip is unique at its own length), and it restores the
249/// trailing ".0" that Rust's plain `{}` drops from whole floats. The sign
250/// comes from the sign *bit*, so `-0.0` prints "-0.0" and earns the negword.
251///
252/// In the exponent windows the shortest digits are recovered from Rust's `{}`
253/// (also shortest-round-trip, hence the same digits as Python's repr) and
254/// re-assembled Python-style: one leading digit, "." only if more digits
255/// follow, and a signed, two-digit-minimum exponent (`1e+16`, `1.5e-05`).
256/// Every f64 at or above 1e16 is whole, so the high side is all integer
257/// digits. Non-finite values print as Python does ('inf'/'nan' — note
258/// CPython drops NaN's sign bit); they are binding-unreachable but kept so
259/// the surface matches `str(float)` everywhere. (Same reconstruction as the
260/// XH port's, which the corpus pins on the identical code shape.)
261fn py_float_str(value: f64, precision: u32) -> String {
262    if value.is_nan() {
263        return "nan".to_string();
264    }
265    let sign = if value.is_sign_negative() { "-" } else { "" };
266    let abs = value.abs();
267    if abs.is_infinite() {
268        return format!("{}inf", sign);
269    }
270    if abs != 0.0 && abs < 1e-4 {
271        // '1e-05' side. Rust `{}` is always positional: "0." + zeros + digits.
272        let s = format!("{}", abs);
273        let frac = &s[2..];
274        let zeros = frac.bytes().take_while(|&b| b == b'0').count();
275        let sig = &frac[zeros..];
276        let coeff = if sig.len() == 1 {
277            sig.to_string()
278        } else {
279            format!("{}.{}", &sig[..1], &sig[1..])
280        };
281        return format!("{}{}e-{:02}", sign, coeff, zeros + 1);
282    }
283    if abs >= 1e16 {
284        // '1e+16' side: at this magnitude `{}` is all integer digits.
285        let s = format!("{}", abs);
286        let sig = s.trim_end_matches('0');
287        let coeff = if sig.len() == 1 {
288            sig.to_string()
289        } else {
290            format!("{}.{}", &sig[..1], &sig[1..])
291        };
292        return format!("{}{}e+{:02}", sign, coeff, s.len() - 1);
293    }
294    format!("{:.*}", precision as usize, value)
295}
296
297/// Python's `Num2Word_NN.to_cardinal` applied to `str(number)` — the shared
298/// body behind the integer, float and Decimal entries:
299///
300/// ```text
301/// n = str(number).strip()
302/// if n.startswith("-"): n = n[1:]; ret = self.negword
303/// else:                 ret = ""
304/// if "." in n:
305///     left, right = n.split(".", 1)
306///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
307///     for digit in right:
308///         ret += self._int_to_word(int(digit)) + " "
309///     return ret.strip()
310/// else:
311///     return (ret + self._int_to_word(int(n))).strip()
312/// ```
313///
314/// `int(...)` on a non-numeric token raises ValueError, and exponent-notation
315/// `str()`s genuinely reach it (bug 9): `'1e+16'`/`'1E+2'` have no "." so the
316/// whole token hits the integer parse, while `'1.5e-05'` splits and then dies
317/// in the digit loop on the exponent marker — `int('e')` (uppercase `int('E')`
318/// for a Decimal). The error escapes before the negword is applied, matching
319/// Python's evaluation order. Both messages mirror CPython's byte for byte,
320/// including which token they quote.
321fn string_to_words(n: &str) -> Result<String> {
322    // `n = str(number).strip()` — no `str()` form can carry edge whitespace,
323    // so the strip is a no-op, mirrored via the input contract.
324    //
325    // `if n.startswith("-"): n = n[1:]; ret = self.negword`
326    let (n, ret) = match n.strip_prefix('-') {
327        Some(rest) => (rest, NEGWORD),
328        None => (n, ""),
329    };
330
331    match n.split_once('.') {
332        Some((left, right)) => {
333            // `ret += _int_to_word(int(left)) + " " + self.pointword + " "`
334            let left_int = BigInt::from_str(left).map_err(|_| int_value_error(left))?;
335            let mut out = format!("{}{} {} ", ret, int_to_word(&left_int), POINTWORD);
336            // `for digit in right: ret += _int_to_word(int(digit)) + " "`
337            for ch in right.chars() {
338                let d = ch
339                    .to_digit(10)
340                    .ok_or_else(|| int_value_error(&ch.to_string()))?;
341                out.push_str(&int_to_word(&BigInt::from(d)));
342                out.push(' ');
343            }
344            // `return ret.strip()`
345            Ok(out.trim().to_string())
346        }
347        None => {
348            // `return (ret + _int_to_word(int(n))).strip()`
349            let n_int = BigInt::from_str(n).map_err(|_| int_value_error(n))?;
350            Ok(format!("{}{}", ret, int_to_word(&n_int)).trim().to_string())
351        }
352    }
353}
354
355/// `forms[1] if n != 1 else forms[0]` — the inline plural test NN's
356/// `to_currency` uses in place of `pluralize` (which it never calls).
357///
358/// Every NN entry carries exactly two forms, so the `Index` arm is
359/// unreachable; it exists so a malformed table cannot panic.
360fn pick(forms: &[String], n: &BigInt) -> Result<String> {
361    let idx = if n.is_one() { 0 } else { 1 };
362    forms
363        .get(idx)
364        .cloned()
365        .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
366}
367
368pub struct LangNn {
369    /// Built once in [`LangNn::new`]; the registry caches the struct in a
370    /// `OnceLock`, so this table is constructed exactly once per process.
371    forms: HashMap<&'static str, CurrencyForms>,
372    /// The `CURRENCY_FORMS.get(currency, <default>)` fallback, resolved once.
373    fallback: CurrencyForms,
374}
375
376impl LangNn {
377    pub fn new() -> Self {
378        let forms = FORMS
379            .iter()
380            .map(|(code, unit, subunit)| (*code, CurrencyForms::new(unit, subunit)))
381            .collect();
382        let (_, unit, subunit) = &FORMS[FALLBACK_INDEX];
383        LangNn {
384            forms,
385            fallback: CurrencyForms::new(unit, subunit),
386        }
387    }
388}
389
390impl Default for LangNn {
391    fn default() -> Self {
392        Self::new()
393    }
394}
395
396impl Lang for LangNn {
397    /// This language's own `to_currency(currency=...)` default,
398    /// read from the live Python signature. Only 44 of 156 use EUR.
399    fn default_currency(&self) -> &str {
400        "NOK"
401    }
402
403    /// This language's own `to_currency(separator=...)` default,
404    /// read from the live Python signature. Base's is ",", but only
405    /// 36 of 149 languages actually use it — most default to " " or a
406    /// conjunction, so inheriting Base's comma silently corrupts them.
407    fn default_separator(&self) -> &str {
408        " "
409    }
410
411    fn negword(&self) -> &str {
412        NEGWORD
413    }
414
415    fn pointword(&self) -> &str {
416        "point"
417    }
418
419    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
420        // Python works on `str(number).strip()` and strips a leading "-"
421        // textually before `int()`-ing the rest; for integer input that is
422        // exactly a sign test plus abs().
423        let (sign, magnitude) = if value.is_negative() {
424            (NEGWORD, value.abs())
425        } else {
426            ("", value.clone())
427        };
428        // The trailing `.strip()` is a no-op for integer input (negword's
429        // trailing space is always followed by a non-empty word, and
430        // `_int_to_word` never returns "" — zero yields "zero"). Mirrored
431        // anyway to match the source line for line.
432        Ok(format!("{}{}", sign, int_to_word(&magnitude))
433            .trim()
434            .to_string())
435    }
436
437    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
438        // bug 6: unconditional suffix on the cardinal, negatives included.
439        Ok(format!("{}-de", self.to_cardinal(value)?))
440    }
441
442    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
443        // `str(number) + "."` — the sign survives: -1 -> "-1.".
444        Ok(format!("{}.", value))
445    }
446
447    fn to_year(&self, value: &BigInt) -> Result<String> {
448        // `to_year(val, longval=True)` ignores longval entirely and delegates.
449        // No BC/AD handling, so -500 -> "minus fem hundre".
450        self.to_cardinal(value)
451    }
452
453    /// Python's `to_cardinal` float/Decimal branch. NN overrides `to_cardinal`
454    /// outright, so it never reaches `base.float2tuple`/`to_cardinal_float`:
455    /// there is no rounding, no `< 0.01` heuristic, and `self.precision` is
456    /// never read. It just does `n = str(number)`, splits on `"."`, words the
457    /// integer part with `_int_to_word`, and words each fractional digit —
458    /// see [`string_to_words`].
459    ///
460    /// Because there is no `float2tuple`, the f64-artefact trap does *not*
461    /// apply here: `str(2.675)` is `"2.675"`, so the digits are `6 7 5`
462    /// (`seks sju fem`) straight off the repr — the same 675 the artefact
463    /// heuristic rescues elsewhere, but reached by a different route.
464    ///
465    /// `precision_override` (the `precision=` kwarg) is **ignored**, matching
466    /// Python: `num2words` pops `precision=` and only reapplies it when the
467    /// converter carries a `.precision` attribute. `Num2Word_NN` never sets
468    /// one (it defines no numword tables, so `Num2Word_Base.__init__`'s
469    /// `self.precision = 2` line is never reached), so the kwarg is dropped.
470    ///
471    /// Reconstructing `str(number)`:
472    /// * **Float** — [`py_float_str`]: `format!("{:.p}", value)` inside repr's
473    ///   fixed window (byte-identical to `str(value)`, sign and negative zero
474    ///   included; verified against every NN float corpus row), Python-style
475    ///   `1e+16`/`1.5e-05` exponent form outside it. The exponent forms
476    ///   contain no "." — they exist so the same inputs raise the same
477    ///   `ValueError`s Python does (bug 9).
478    /// * **Decimal** — [`python_decimal_str`], CPython's `Decimal.__str__`:
479    ///   scale is preserved, so `"1.10"` keeps its trailing zero
480    ///   (`ein point ein zero`), the #603 value `98746251323029.99` never
481    ///   float-casts, and the exponent-form strings (`"1E+2"`, `"1E+20"`,
482    ///   `"1E-7"`) come out exactly as Python prints them — and then fail
483    ///   `int()` exactly as Python does.
484    fn to_cardinal_float(
485        &self,
486        value: &FloatValue,
487        _precision_override: Option<u32>,
488    ) -> Result<String> {
489        let n = match value {
490            FloatValue::Float { value, precision } => py_float_str(*value, *precision),
491            FloatValue::Decimal { value, .. } => python_decimal_str(value),
492        };
493        string_to_words(&n)
494    }
495
496    // ---- float/Decimal routing ----------------------------------------
497
498    /// `to_cardinal(float/Decimal)` full routing. Python's `to_cardinal` keys
499    /// on `str(number)`, so *every* float/Decimal takes the string route —
500    /// whole values included: 5.0 keeps its ".0" tail ("fem point zero"),
501    /// `Decimal("5.00")` speaks both zeros, and only a point-less string
502    /// (`Decimal("5")` -> "5") lands on the integer branch, via the same
503    /// `int(n)` call. Base's whole-value shortcut never runs.
504    fn cardinal_float_entry(
505        &self,
506        value: &FloatValue,
507        precision_override: Option<u32>,
508    ) -> Result<String> {
509        self.to_cardinal_float(value, precision_override)
510    }
511
512    /// `to_ordinal(float/Decimal)`. `Num2Word_NN.to_ordinal` is
513    /// `self.to_cardinal(number) + "-de"` with **no type guard**, so a float
514    /// or Decimal rides the same string route as `to_cardinal` and then takes
515    /// the suffix: `to_ordinal(5.0)` == "fem point zero-de", `to_ordinal(-0.0)`
516    /// == "minus zero point zero-de", and a whole `Decimal("100")` ==
517    /// "ein hundre-de". An exponent-notation repr (`str(1e16)` == "1e+16",
518    /// `str(Decimal("1E+2"))` == "1E+2") makes the inner `int()` raise its
519    /// ValueError *before* Python's `+ "-de"` runs — the `?` reproduces that
520    /// ordering.
521    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
522        Ok(format!("{}-de", self.cardinal_float_entry(value, None)?))
523    }
524
525    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` never casts to
526    /// int, so it *succeeds* on every float and Decimal — "5.0.", "-0.0.",
527    /// "1e+16." and "1E+2." are all real Python outputs. `repr_str` is the
528    /// binding's Python `str(value)`, exactly the string Python concatenates.
529    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
530        Ok(format!("{}.", repr_str))
531    }
532
533    // `year_float_entry` is deliberately NOT overridden: the trait default
534    // routes to `cardinal_float_entry`, and Python's `to_year` is a plain
535    // `return self.to_cardinal(val)` — identical by construction.
536
537    /// `converter.str_to_number` — NN inherits `Num2Word_Base`'s
538    /// `Decimal(value)`, so `"Infinity"`/`"NaN"` strings parse *successfully*
539    /// and only blow up later, inside `to_cardinal`'s `int("Infinity")` /
540    /// `int("NaN")`. The non-finite sentinels pass straight through here and
541    /// the mode-aware [`inf_result`](Self::inf_result) / [`nan_result`](Self::nan_result)
542    /// hooks reproduce the exact per-mode Python behaviour natively — no
543    /// fallback. Finite parses are returned unchanged. See bug 10.
544    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
545        python_decimal_parse(s)
546    }
547
548    /// `Decimal('Infinity')` / `-Infinity`. `to_cardinal` strips a leading
549    /// "-" and then `int("Infinity")` raises `ValueError: invalid literal for
550    /// int() with base 10: 'Infinity'` (both signs quote 'Infinity', the sign
551    /// having been stripped); `to_ordinal` appends "-de" *after* that int(),
552    /// so it raises identically, and `to_year` == `to_cardinal`.
553    /// `to_ordinal_num` is `str(number) + "."` and never calls `int()`, so it
554    /// succeeds with "Infinity." / "-Infinity." (bug 9).
555    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
556        if to == "ordinal_num" {
557            return Ok(format!("{}Infinity.", if negative { "-" } else { "" }));
558        }
559        Err(int_value_error("Infinity"))
560    }
561
562    /// `Decimal('NaN')`. `str(Decimal('NaN'))` is "NaN"; `int("NaN")` raises
563    /// `ValueError: invalid literal for int() with base 10: 'NaN'` on the
564    /// cardinal/ordinal/year paths, while `to_ordinal_num` echoes "NaN.".
565    fn nan_result(&self, to: &str) -> Result<String> {
566        if to == "ordinal_num" {
567            return Ok("NaN.".into());
568        }
569        Err(int_value_error("NaN"))
570    }
571
572    // ---- currency ----------------------------------------------------
573    //
574    // `Num2Word_NN` overrides `to_currency` outright and inherits `to_cheque`
575    // from `Num2Word_Base`. The two disagree about unknown codes, and that
576    // asymmetry is real (bug 7): `to_currency` does
577    // `CURRENCY_FORMS.get(currency, <first value>)` and silently renders an
578    // unknown code as *kroner*, while `to_cheque` does
579    // `CURRENCY_FORMS[currency]` and turns the KeyError into a
580    // NotImplementedError. The corpus pins both:
581    //
582    //     currency:JPY 12.34 -> "ti to kroner tretti fire øre"
583    //     cheque:JPY   1234.56 -> NotImplementedError
584    //
585    // So `currency_forms` below reports only the three real codes (which is
586    // what drives the inherited `to_cheque` to raise), and `to_currency`
587    // applies the fallback itself rather than going through the hook.
588    //
589    // Not overridden, deliberately:
590    // * `currency_adjective` — `CURRENCY_ADJECTIVES` is `{}`, and NN's
591    //   `to_currency` ignores the `adjective` argument outright anyway.
592    // * `currency_precision` — `CURRENCY_PRECISION` is `{}`, so every code is
593    //   100. NN's `to_currency` never consults it at all (it hardcodes two
594    //   decimal places), which is why JPY renders øre and KWD renders /100.
595    // * `pluralize` — NN never calls it (it indexes the forms inline) and
596    //   defines none, so the trait default (NotImplementedError, matching
597    //   `Num2Word_Base.pluralize`) is correct and unreachable.
598    // * `money_verbose` — base's default delegates to `to_cardinal`, which is
599    //   what `Num2Word_NN` inherits; the trait default already does this.
600    // * `cents_verbose` / `cents_terse` / `cardinal_from_decimal` — NN's
601    //   `to_currency` reaches none of them (`cents=False` drops the segment
602    //   entirely rather than falling back to terse digits).
603
604    fn lang_name(&self) -> &str {
605        "Num2Word_NN"
606    }
607
608    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
609        // Deliberately *not* the fallback lookup — this hook backs the
610        // inherited `to_cheque`, whose `CURRENCY_FORMS[currency]` raises.
611        self.forms.get(code)
612    }
613
614    fn to_currency(
615        &self,
616        val: &CurrencyValue,
617        currency: &str,
618        cents: bool,
619        separator: Option<&str>,
620        // NN's signature takes `adjective=False` and then never reads it.
621        _adjective: bool,
622    ) -> Result<String> {
623        // Trait now hands us None when the caller omitted separator=;
624        // resolve it to this language's own default before the ported body.
625        let separator = separator.unwrap_or(self.default_separator());
626        // Python's `separator=" "` default cannot survive the FFI boundary:
627        // the shim and the diff harness both pass base's "," when the caller
628        // supplied nothing, so "," is the only available "unset" signal. Map
629        // it back to NN's own default. This matches the Python converter for
630        // every call except an explicit `separator=","`, which is
631        // indistinguishable from the default here — see `concerns`.
632        let separator = if separator == BASE_DEFAULT_SEPARATOR {
633            DEFAULT_SEPARATOR
634        } else {
635            separator
636        };
637
638        // `if val < 0: is_negative = True; val = abs(val)`
639        let is_negative = val.is_negative();
640
641        // `parts = str(val).split(".")`, evaluated *after* the abs().
642        //
643        // The Decimal arm carries `str(value)` as parsed on the Python side,
644        // and BigDecimal's Display round-trips it (scale is preserved, so
645        // "1.0" stays "1.0" and keeps its empty-cents part). The Int arm has
646        // no ".", so `parts` has length 1 and cents are skipped — but note
647        // that is *not* the int/float distinction base.to_currency draws:
648        // NN reaches the same result for 1 and 1.0 because "1.0" yields
649        // parts[1] == "0" -> right == 0 -> the falsy `and right` test.
650        let text = match val {
651            CurrencyValue::Int(v) => v.abs().to_string(),
652            CurrencyValue::Decimal { value: d, .. } => d.abs().to_string(),
653        };
654        let parts: Vec<&str> = text.split('.').collect();
655
656        // `left = int(parts[0]) if parts[0] else 0`
657        let left = if parts[0].is_empty() {
658            BigInt::zero()
659        } else {
660            BigInt::from_str(parts[0]).map_err(|_| int_value_error(parts[0]))?
661        };
662
663        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
664        //
665        // Truncation, not rounding: 12.999 -> "99" -> nitti ni øre, and
666        // 0.001 -> "00" -> 0 -> no cents segment at all.
667        let right = match parts.get(1) {
668            Some(frac) if !frac.is_empty() => {
669                let mut digits: String = frac.chars().take(2).collect();
670                while digits.chars().count() < 2 {
671                    digits.push('0');
672                }
673                BigInt::from_str(&digits).map_err(|_| int_value_error(&digits))?
674            }
675            _ => BigInt::zero(),
676        };
677
678        // `cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(...values())[0])`
679        let forms = self.forms.get(currency).unwrap_or(&self.fallback);
680
681        let mut result = format!("{} {}", int_to_word(&left), pick(&forms.unit, &left)?);
682
683        // `if cents and right:` — a zero `right` drops the segment, and so
684        // does `cents=False` (NN has no terse-digits branch).
685        if cents && !right.is_zero() {
686            result.push_str(separator);
687            result.push_str(&format!(
688                "{} {}",
689                int_to_word(&right),
690                pick(&forms.subunit, &right)?
691            ));
692        }
693
694        if is_negative {
695            result = format!("{}{}", NEGWORD, result);
696        }
697
698        Ok(result.trim().to_string())
699    }
700}