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}