Skip to main content

num2words2_core/
lang_nb.rs

1//! Port of `lang_NO.py` (Norwegian Bokmål) — registry key **"nb"**.
2//!
3//! `CONVERTER_CLASSES["nb"]` is `lang_NO.Num2Word_NO` (an alias of `"no"`),
4//! so this is a port of `Num2Word_NO`, not of any `Num2Word_NB`.
5//!
6//! Shape: **engine**. `Num2Word_NO(lang_EUR.Num2Word_EUR)` defines
7//! `high_numwords` (via EUR's `setup`), `mid_numwords`, `low_numwords` and
8//! `merge`, and lets `Num2Word_Base.to_cardinal` drive `splitnum`/`clean`.
9//! So `cards`/`maxval`/`merge` are all supplied here and the default
10//! `to_cardinal` in `base.rs` runs unchanged.
11//!
12//! Inheritance chain walked: `Num2Word_NO` → `Num2Word_EUR` → `Num2Word_Base`.
13//! (`lang_EU` is Basque and is *not* in this chain despite the similar name.)
14//!
15//! `set_high_numwords` is redefined verbatim in `lang_NO.py` but is byte-identical
16//! to the `Num2Word_EUR` version it shadows, so the long scale applies:
17//! `GIGA_SUFFIX="illiard"` at 10^n and `MEGA_SUFFIX="illion"` at 10^(n-3),
18//! stepping n by -6. With 100 high words, n runs 603 → 9, giving
19//! `cards[10^9]="milliard"` / `cards[10^6]="million"` at the bottom and
20//! `cards[10^603]="centilliard"` at the top. Hence `MAXVAL = 1000 * 10^603
21//! = 10^606` — the reason values must stay `BigInt`.
22//!
23//! Inherited unchanged from `Num2Word_Base` (trait defaults are wrong for
24//! these, so they are overridden below to match Python):
25//!   * `to_splitnum` — reimplemented here as a private helper; `to_year` needs it.
26//!   * `inflect`, `verify_ordinal` — private helpers below.
27//!
28//! # Faithfully reproduced Python bugs
29//!
30//! This is a port, not a rewrite. Every one of the following looks wrong and
31//! is exactly what Python emits (each is pinned by a `bench/corpus.jsonl` row):
32//!
33//! 1. **`to_ordinal` suffix matching is order-dependent and over-eager.**
34//!    `to_ordinal` walks `ords_pl` in dict *insertion* order, takes the first
35//!    key that is a suffix of the cardinal, and `break`s; then it walks
36//!    `ords_sg` (`{"en": "første"}`) the same way. The `ords_sg` pass is not
37//!    guarded by whether the `ords_pl` pass already fired, and the source
38//!    comment claims splitting the tables "needs to be done separately to not
39//!    block 13-19 to_ordinal" — but it does not achieve that:
40//!    - `to_ordinal(13)`: "tretten" matches no `ords_pl` key, then hits the
41//!      `ords_sg` key "en" → **"trettførste"** (not "trettende").
42//!    - `to_ordinal(21)`: "tjueen" → **"tjueførste"**; likewise 31, 71, 81, 91.
43//!    Insertion order is therefore load-bearing and [`LangNb::ords_pl`] is a
44//!    `Vec`, never a `HashMap`. E.g. "to" precedes "tre"/"tolv", so
45//!    `to_ordinal(42)` = "førtito" → **"førtiandre"**.
46//! 2. **Ordinals silently pass through above 10^6.** `ords_pl` stops at
47//!    "million", and the plural forms "millioner"/"milliarder" that `merge`
48//!    produces match no key at all, so `to_ordinal(10**7)` == "ti millioner"
49//!    and `to_ordinal(10**9)` == "en milliard" — plain cardinals, no ordinal
50//!    marking whatsoever.
51//! 3. **`merge` discards `ltext` at exactly 100000.** The `lnum == 100 and
52//!    rnum == 1000` arm returns the hard-coded `("hundre tusen", 100000)`,
53//!    throwing away the "ett hundre" its own recursion just built. 200000
54//!    keeps the general path and yields "to hundre tusen", so the table is
55//!    inconsistent by design.
56//! 4. **`to_year` is asymmetric about the neuter "ett".** `to_year` builds
57//!    "hundre" itself via `to_splitnum` rather than through `merge`, so
58//!    `to_year(100)` == "en hundre" while `to_cardinal(100)` == "ett hundre".
59//! 5. **`to_year` on negatives is nonsense but well-defined.** Python's floor
60//!    `divmod(-44, 100)` == `(-1, 56)`, so `to_year(-44)` ==
61//!    **"minus en hundre og femtiseks"**. Reproduced with `div_mod_floor`;
62//!    Rust's native `/` and `%` truncate toward zero and would give
63//!    `(0, -44)` — a different, wrong answer.
64//! 6. **`to_currency`'s "og null øre" strip is hardcoded and separator-blind.**
65//!    `Num2Word_NO.to_currency` post-processes Base's output with the literal
66//!    `result.replace(" og null øre", "")`. The separator is baked into that
67//!    literal, so the strip silently stops working the moment the caller passes
68//!    anything but the default `separator=" og"` — verified against the live
69//!    interpreter:
70//!    - `to_currency(1.0, "NOK")` → "en krone"  *(stripped)*
71//!    - `to_currency(1.0, "NOK", separator=" plus")` → "en krone plus null øre"
72//!    - `to_currency(1.0, "NOK", cents=False)` → "en krone og 00 øre"
73//!      *(terse cents render "00", not "null", so the literal misses)*
74//!
75//!    It is also currency-blind in the other direction: it only ever matches
76//!    "øre", so EUR/USD keep their zero cents — `to_currency(1.0, "EUR")` ==
77//!    "en euro og null cent" (corpus-pinned), where NOK would drop the tail.
78//!    Ported as a literal `str::replace`, which — like Python's — replaces
79//!    *every* occurrence, not just the first.
80//! 7. **The inherited currency adjectives are almost all unreachable.**
81//!    `CURRENCY_ADJECTIVES` comes from `Num2Word_EUR` and carries 16 codes, but
82//!    the adjective is only consulted *after* the `CURRENCY_FORMS` lookup
83//!    succeeds, and `Num2Word_NO` narrows that table to NOK/EUR/USD. So only
84//!    NOK ("Norwegian") and USD ("US") can ever fire; EUR is not in the
85//!    adjective table at all, making `adjective=True` a no-op for it. The full
86//!    table is still reproduced — it is what the class actually inherits.
87//! 8. **`to_year` on a float ignores the divisor and splits at the decimal
88//!    point.** `to_splitnum` starts with `if isinstance(val, float): high, low
89//!    = self.float2tuple(val)` — integer part and *fractional digits*, not
90//!    `divmod(val, 100)`. So `to_year(100.0)` == "ett hundre hundre" (high is
91//!    100, not 1) and `to_year(-1.5)` == "minus en hundre og fem" (high -1,
92//!    low 5), while `to_year(Decimal("100"))` takes the `divmod` arm and gives
93//!    "en hundre". Both are corpus-pinned and reproduced in
94//!    [`Lang::year_float_entry`].
95//! 9. **`to_year`'s guard flips semantics between float and Decimal.**
96//!    `(val // 100) % 10` *floors* for floats (`-21.0 // 100 == -1.0`, so
97//!    -21.0 takes the "hundre" arm → "minus tjueen hundre") but *truncates*
98//!    for Decimals (`Decimal("-21.0") // 100 == Decimal("-0")`, guard falsy →
99//!    plain cardinal "minus tjueen"). Same number, different words, purely by
100//!    input type.
101//! 10. **`verify_ordinal` accepts whole floats and `-0.0`.** Both checks are
102//!    numeric (`value == int(value)`, `abs(value) == value`), so
103//!    `to_ordinal(5.0)` == "femte", `to_ordinal(-0.0)` == "null" (IEEE
104//!    `abs(-0.0) == -0.0` is true), while any fractional value raises the
105//!    *float* TypeError before a negative one can raise the *negative* one
106//!    (-1.5 → errmsg_floatord, -2.0 → errmsg_negord).
107//! 11. **`to_ordinal_num` glues "." onto `str(value)` verbatim**, so floats
108//!    keep their repr: `to_ordinal_num(5.0)` == "5.0.", `Decimal("5.00")` →
109//!    "5.00.", `1e16` → "1e+16.", `-0.0` → "-0.0.".
110//!
111//! # Currency surface
112//!
113//! `Num2Word_NO` declares its **own** `CURRENCY_FORMS` class attribute, so it
114//! does *not* alias the shared `Num2Word_EUR.CURRENCY_FORMS` dict that
115//! `Num2Word_EN.__init__` rewrites in place at import time. Verified against
116//! the live interpreter (`Num2Word_NO.CURRENCY_FORMS is
117//! Num2Word_EUR.CURRENCY_FORMS` → `False`), so the EUR-mutation trap described
118//! in `PORTING_CURRENCY.md` does not apply here: NO sees exactly the three
119//! codes its own literal lists, and EUR keeps the un-English `("euro", "euro")`
120//! plural. Every other code — GBP, JPY, KWD, BHD, INR, CNY, CHF — raises
121//! NotImplementedError, which is what 84 of the 108 corpus currency rows assert.
122//!
123//! `CURRENCY_PRECISION` is inherited from `Num2Word_Base` and is `{}`, so
124//! `.get(code, 100)` is always 100 and [`Lang::currency_precision`]'s default
125//! is already exact — it is deliberately **not** overridden. There is therefore
126//! no reachable divisor-1000 (KWD/BHD) or divisor-1 (JPY) path in this
127//! language: those codes die at the forms lookup first.
128//!
129//! `_money_verbose`, `_cents_verbose`, `_cents_terse` and `to_cheque` are all
130//! `Num2Word_Base`'s, matching the trait defaults, so none are overridden.
131
132use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
133use crate::currency::{CurrencyForms, CurrencyValue};
134use crate::floatpath::{float2tuple, FloatValue};
135use bigdecimal::BigDecimal;
136use num_bigint::BigInt;
137use num_integer::Integer;
138use num_traits::{One, Signed, Zero};
139use std::collections::HashMap;
140
141/// Port of `Num2Word_EUR.gen_high_numwords`.
142///
143/// Duplicated locally rather than imported from `lang_en` so this module has
144/// no cross-language dependency (the registry is generated mechanically).
145fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
146    // Python: [u + t for t in tens for u in units] — `tens` is the OUTER loop.
147    let mut out: Vec<String> = Vec::new();
148    for t in tens {
149        for u in units {
150            out.push(format!("{}{}", u, t));
151        }
152    }
153    out.reverse();
154
155    const REPLACEMENTS: &[(&str, &str)] = &[
156        ("novemn", "noven"),
157        ("novemo", "novo"),
158        ("octoo", "octo"),
159        ("quintd", "quind"),
160        ("quintn", "quin"),
161        ("quintq", "quinq"),
162        ("quints", "quins"),
163        ("quintt", "quint"),
164        ("quintv", "quinv"),
165        ("septenn", "septen"),
166        ("septent", "sept"),
167        ("sexn", "sen"),
168        ("sexs", "ses"),
169        ("tresd", "tred"),
170        ("tresn", "tren"),
171        ("tress", "tres"),
172        ("tresv", "trev"),
173        ("unno", "uno"),
174    ];
175    for (k, v) in REPLACEMENTS {
176        out = out.iter().map(|o| o.replace(k, v)).collect();
177    }
178    out.extend(lows.iter().map(|s| s.to_string()));
179    out
180}
181
182/// Port of `Num2Word_Base.inflect` (the "trivial version").
183///
184/// `"".split("/")` is `[""]` in Python and `[""]` in Rust, so the empty-text
185/// case agrees without a special branch.
186fn inflect(value: &BigInt, text: &str) -> String {
187    let parts: Vec<&str> = text.split('/').collect();
188    if value.is_one() {
189        parts[0].to_string()
190    } else {
191        parts.concat()
192    }
193}
194
195/// Python `str(float)`/`repr(float)`, used only to build error-message text
196/// (`errmsg_floatord`, `errmsg_negord`). The corpora compare exception
197/// *types*; the text follows the Python format strings. Whole floats keep
198/// their ".0" ("21.0"), |v| >= 1e16 switches to Python's exponent form
199/// ("1e+20"), and -0.0 keeps its sign.
200fn py_float_str(f: f64) -> String {
201    if f.is_nan() {
202        return "nan".to_string();
203    }
204    if f.is_infinite() {
205        return if f.is_sign_positive() {
206            "inf".to_string()
207        } else {
208            "-inf".to_string()
209        };
210    }
211    let a = f.abs();
212    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
213        // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
214        let s = format!("{:e}", f);
215        if let Some((m, e)) = s.split_once('e') {
216            let (sign, digits) = match e.strip_prefix('-') {
217                Some(d) => ("-", d.to_string()),
218                None => ("+", e.to_string()),
219            };
220            let digits = if digits.len() < 2 {
221                format!("0{}", digits)
222            } else {
223                digits
224            };
225            return format!("{}e{}{}", m, sign, digits);
226        }
227        s
228    } else if f.fract() == 0.0 {
229        // repr keeps the trailing ".0" that Rust's `{}` would drop.
230        format!("{:.1}", f)
231    } else {
232        format!("{}", f)
233    }
234}
235
236/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
237fn py_num_str(v: &FloatValue) -> String {
238    match v {
239        FloatValue::Float { value, .. } => py_float_str(*value),
240        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
241    }
242}
243
244/// CPython `float_divmod`'s floor-division half (`x // y` for floats).
245///
246/// NOT `(x / y).floor()`: CPython computes fmod first and derives the
247/// quotient from it, with a half-ulp correction. `to_year`'s guard hits this
248/// with negative floats (`-21.0 // 100 == -1.0`), which is exactly where
249/// floor and truncation part ways.
250fn py_f64_floordiv(x: f64, y: f64) -> f64 {
251    let m = x % y; // Rust f64 `%` is fmod, same as C's.
252    let mut div = (x - m) / y;
253    if m != 0.0 && ((y < 0.0) != (m < 0.0)) {
254        // CPython also folds the fmod into the divisor's sign here; only
255        // the quotient correction matters for `//`.
256        div -= 1.0;
257    }
258    if div != 0.0 {
259        let floordiv = div.floor();
260        if div - floordiv > 0.5 {
261            floordiv + 1.0
262        } else {
263            floordiv
264        }
265    } else {
266        0.0f64.copysign(x / y)
267    }
268}
269
270/// CPython `float_divmod`'s modulo half (`x % y` for floats): the result
271/// takes the *divisor*'s sign, unlike fmod.
272fn py_f64_mod(x: f64, y: f64) -> f64 {
273    let mut m = x % y;
274    if m != 0.0 {
275        if (y < 0.0) != (m < 0.0) {
276            m += y;
277        }
278    } else {
279        m = 0.0f64.copysign(y);
280    }
281    m
282}
283
284/// `Num2Word_NO.CURRENCY_FORMS` — the class's own literal, not EUR's dict.
285///
286/// Three codes, and note the arity: every entry has exactly two forms, and for
287/// EUR/USD/øre the two are *identical* ("euro"/"euro", "dollar"/"dollar",
288/// "øre"/"øre"). That is not redundancy to collapse — `pluralize` indexes
289/// `forms[0 if n == 1 else 1]`, so both slots must exist or the lookup would
290/// IndexError. Only NOK inflects: "en krone" / "to kroner".
291fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
292    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
293    m.insert(
294        "NOK",
295        CurrencyForms::new(&["krone", "kroner"], &["\u{f8}re", "\u{f8}re"]),
296    );
297    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "cent"]));
298    m.insert(
299        "USD",
300        CurrencyForms::new(&["dollar", "dollar"], &["cent", "cent"]),
301    );
302    m
303}
304
305/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited verbatim — `Num2Word_NO` does
306/// not define its own. Only NOK and USD are reachable; see module bug (7).
307fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
308    [
309        ("AUD", "Australian"),
310        ("BYN", "Belarusian"),
311        ("CAD", "Canadian"),
312        ("EEK", "Estonian"),
313        ("USD", "US"),
314        ("RUB", "Russian"),
315        ("NOK", "Norwegian"),
316        ("MXN", "Mexican"),
317        ("RON", "Romanian"),
318        ("INR", "Indian"),
319        ("HUF", "Hungarian"),
320        ("ISK", "\u{ed}slenskar"),
321        ("UZS", "Uzbekistan"),
322        ("SAR", "Saudi"),
323        ("JPY", "Japanese"),
324        ("KRW", "Korean"),
325    ]
326    .into_iter()
327    .collect()
328}
329
330pub struct LangNb {
331    cards: Cards,
332    maxval: BigInt,
333    /// Built once in `new()`. Rebuilding per call made an earlier version of
334    /// this port 10x slower than the Python it replaces.
335    currency_forms: HashMap<&'static str, CurrencyForms>,
336    currency_adjectives: HashMap<&'static str, &'static str>,
337    /// Ordered, NOT a map: `to_ordinal` breaks on the first suffix hit and
338    /// Python iterates this dict in insertion order. See module bug (1).
339    ords_pl: Vec<(&'static str, &'static str)>,
340    /// `{"en": "første"}` — applied in a second, unguarded pass.
341    ords_sg: Vec<(&'static str, &'static str)>,
342    exclude_title: Vec<String>,
343}
344
345impl Default for LangNb {
346    fn default() -> Self {
347        Self::new()
348    }
349}
350
351impl LangNb {
352    pub fn new() -> Self {
353        // Num2Word_EUR.setup()
354        let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
355        let units = [
356            "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
357        ];
358        let tens = [
359            "dec",
360            "vigint",
361            "trigint",
362            "quadragint",
363            "quinquagint",
364            "sexagint",
365            "septuagint",
366            "octogint",
367            "nonagint",
368        ];
369        let mut high = vec!["cent".to_string()];
370        high.extend(gen_high_numwords(&units, &tens, &lows));
371
372        let mut cards = Cards::new();
373
374        // Num2Word_NO.set_high_numwords:
375        //   cap = 3 + 6 * len(high)
376        //   for word, n in zip(high, range(cap, 3, -6)):
377        //       cards[10**n]     = word + GIGA_SUFFIX  ("illiard")
378        //       cards[10**(n-3)] = word + MEGA_SUFFIX  ("illion")
379        // len(high) == 100 and range(603, 3, -6) has 100 elements, so zip()
380        // consumes both exactly; the `n <= 3` guard mirrors the range bound
381        // for any hypothetical length mismatch (zip stops at the shorter).
382        let cap: i64 = 3 + 6 * high.len() as i64;
383        let mut n = cap;
384        for word in high.iter() {
385            if n <= 3 {
386                break;
387            }
388            cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}illiard", word));
389            cards.insert(
390                BigInt::from(10u8).pow((n - 3) as u32),
391                format!("{}illion", word),
392            );
393            n -= 6;
394        }
395
396        set_mid_numwords(
397            &mut cards,
398            &[
399                (1000, "tusen"),
400                (100, "hundre"),
401                (90, "nitti"),
402                (80, "\u{e5}tti"),
403                (70, "sytti"),
404                (60, "seksti"),
405                (50, "femti"),
406                (40, "f\u{f8}rti"),
407                (30, "tretti"),
408            ],
409        );
410        set_low_numwords(
411            &mut cards,
412            &[
413                "tjue",
414                "nitten",
415                "atten",
416                "sytten",
417                "seksten",
418                "femten",
419                "fjorten",
420                "tretten",
421                "tolv",
422                "elleve",
423                "ti",
424                "ni",
425                "\u{e5}tte",
426                "syv",
427                "seks",
428                "fem",
429                "fire",
430                "tre",
431                "to",
432                "en",
433                "null",
434            ],
435        );
436
437        // MAXVAL = 1000 * list(self.cards.keys())[0]  → 1000 * 10^603 == 10^606
438        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
439
440        // ORDER IS SEMANTICS — do not sort, do not turn into a HashMap.
441        let ords_pl: Vec<(&'static str, &'static str)> = vec![
442            ("to", "andre"),
443            ("tre", "tredje"),
444            ("fire", "fjerde"),
445            ("fem", "femte"),
446            ("seks", "sjette"),
447            ("syv", "syvende"),
448            ("\u{e5}tte", "\u{e5}ttende"),
449            ("ni", "niende"),
450            ("ti", "tiende"),
451            ("elleve", "ellevte"),
452            ("tolv", "tolvte"),
453            ("fjorten", "fjortende"),
454            ("femten", "femtende"),
455            ("seksten", "sekstende"),
456            ("sytten", "syttende"),
457            ("atten", "attende"),
458            ("nitten", "nittende"),
459            ("tjue", "tjuende"),
460            ("hundre", "hundrede"),
461            ("tusen", "tusende"),
462            ("million", "millionte"),
463        ];
464        let ords_sg: Vec<(&'static str, &'static str)> = vec![("en", "f\u{f8}rste")];
465
466        LangNb {
467            cards,
468            maxval,
469            currency_forms: build_currency_forms(),
470            currency_adjectives: build_currency_adjectives(),
471            ords_pl,
472            ords_sg,
473            exclude_title: vec!["og".into(), "komma".into(), "minus".into()],
474        }
475    }
476
477    /// Port of `Num2Word_Base.verify_ordinal`.
478    ///
479    /// The float branch (`errmsg_floatord`) is unreachable here: input is
480    /// always an integer `BigInt`. Only the negative branch can fire.
481    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
482        if value.is_negative() {
483            return Err(N2WError::Type(format!(
484                "Cannot treat negative num {} as ordinal.",
485                value
486            )));
487        }
488        Ok(())
489    }
490
491    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
492    /// Python's order:
493    ///
494    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
495    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
496    ///
497    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
498    /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is "null",
499    /// not an error. A negative fractional value (-1.5) fails check 1 first,
500    /// so it raises the *float* message, as Python does (module bug 10).
501    /// Returns the integral value for the integer-path continuation.
502    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
503        match value.as_whole_int() {
504            Some(i) => {
505                if i.is_negative() {
506                    Err(N2WError::Type(format!(
507                        "Cannot treat negative num {} as ordinal.",
508                        py_num_str(value)
509                    )))
510                } else {
511                    Ok(i)
512                }
513            }
514            None => Err(N2WError::Type(format!(
515                "Cannot treat float {} as ordinal.",
516                py_num_str(value)
517            ))),
518        }
519    }
520
521    /// Port of `Num2Word_Base.to_splitnum`, restricted to integral input.
522    ///
523    /// Python tries `high, low = val` first, which raises `TypeError` for an
524    /// `int` and falls through to `divmod(val, divisor)` — so the tuple-unpack
525    /// path is dead for our inputs and only the `divmod` path is modelled.
526    /// `divmod` floors, which is what makes `to_year` on negatives behave the
527    /// way it does; see module bug (5).
528    #[allow(clippy::too_many_arguments)]
529    fn to_splitnum(
530        &self,
531        val: &BigInt,
532        hightxt: &str,
533        lowtxt: &str,
534        jointxt: &str,
535        divisor: &BigInt,
536        longval: bool,
537        cents: bool,
538    ) -> Result<String> {
539        let mut out: Vec<String> = Vec::new();
540        let (high, low) = val.div_mod_floor(divisor);
541
542        if !high.is_zero() {
543            // Python rebinds `hightxt` to the inflected+titled form here, and
544            // the `if hightxt:` tests below see the REBOUND value.
545            let hightxt = self.title(&inflect(&high, hightxt));
546            out.push(self.to_cardinal(&high)?);
547            if !low.is_zero() {
548                if longval {
549                    if !hightxt.is_empty() {
550                        out.push(hightxt);
551                    }
552                    if !jointxt.is_empty() {
553                        out.push(self.title(jointxt));
554                    }
555                }
556            } else if !hightxt.is_empty() {
557                out.push(hightxt);
558            }
559        }
560
561        if !low.is_zero() {
562            if cents {
563                out.push(self.to_cardinal(&low)?);
564            } else {
565                // Python: "%02d" % low. Unreachable from to_year (cents=True),
566                // kept for fidelity. `low` is non-negative (floored divmod with
567                // a positive divisor), so zero-padding to width 2 is exact.
568                let s = low.to_string();
569                out.push(if s.len() < 2 {
570                    format!("{:0>2}", s)
571                } else {
572                    s
573                });
574            }
575            if !lowtxt.is_empty() && longval {
576                out.push(self.title(&inflect(&low, lowtxt)));
577            }
578        }
579
580        Ok(out.join(" "))
581    }
582}
583
584impl Lang for LangNb {
585    /// This language's own `to_currency(currency=...)` default,
586    /// read from the live Python signature. Only 44 of 156 use EUR.
587    fn default_currency(&self) -> &str {
588        "NOK"
589    }
590
591    /// This language's own `to_currency(separator=...)` default,
592    /// read from the live Python signature. Base's is ",", but only
593    /// 36 of 149 languages actually use it — most default to " " or a
594    /// conjunction, so inheriting Base's comma silently corrupts them.
595    fn default_separator(&self) -> &str {
596        " og"
597    }
598
599    fn cards(&self) -> &Cards {
600        &self.cards
601    }
602    fn maxval(&self) -> &BigInt {
603        &self.maxval
604    }
605    fn negword(&self) -> &str {
606        "minus "
607    }
608    fn pointword(&self) -> &str {
609        "komma"
610    }
611    fn exclude_title(&self) -> &[String] {
612        &self.exclude_title
613    }
614
615    /// Port of `Num2Word_NO.merge`. Arm order is exact: several arms overlap
616    /// and only the first match may fire.
617    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
618        let (ltext, lnum) = l;
619        let (rtext, rnum) = r;
620        let hundred = BigInt::from(100);
621        let thousand = BigInt::from(1000);
622        let million = BigInt::from(10u8).pow(6);
623        let milliard = BigInt::from(10u8).pow(9);
624
625        if lnum.is_one() && rnum < &hundred {
626            // 1 + <100: drop the "en" entirely (splitnum always emits it).
627            (rtext.to_string(), rnum.clone())
628        } else if &hundred > lnum && lnum > rnum {
629            // Tens+units are written solid: 23 → "tjuetre", 42 → "førtito".
630            (format!("{}{}", ltext, rtext), lnum + rnum)
631        } else if lnum >= &hundred && &hundred > rnum {
632            (format!("{} og {}", ltext, rtext), lnum + rnum)
633        } else if rnum > lnum {
634            if lnum.is_one() && (rnum == &hundred || rnum == &thousand) {
635                // 'hundre'/'tusen' are neuter → the numeral 1 is "ett", not "en".
636                (format!("ett {}", rtext), lnum * rnum)
637            } else if lnum == &hundred && rnum == &thousand {
638                // Hard-coded; discards ltext ("ett hundre"). See module bug (3).
639                ("hundre tusen".to_string(), BigInt::from(100000))
640            } else if rnum == &million {
641                if lnum.is_one() {
642                    ("en million".to_string(), lnum * rnum)
643                } else {
644                    (format!("{} millioner", ltext), lnum * rnum)
645                }
646            } else if rnum == &milliard {
647                if lnum.is_one() {
648                    ("en milliard".to_string(), lnum * rnum)
649                } else {
650                    (format!("{} milliarder", ltext), lnum * rnum)
651                }
652            } else {
653                // Note: 10^12 and up get NO plural form — "to billion", not
654                // "to billioner". Only million/milliard are special-cased.
655                (format!("{} {}", ltext, rtext), lnum * rnum)
656            }
657        } else {
658            (format!("{} {}", ltext, rtext), lnum + rnum)
659        }
660    }
661
662    /// Port of `Num2Word_NO.to_ordinal`. See module bugs (1) and (2).
663    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
664        self.verify_ordinal(value)?;
665        let mut outword = self.to_cardinal(value)?;
666
667        // Python: outword[: len(outword) - len(key)] + ords_pl[key], then break.
668        // `ends_with` guarantees the split point is a char boundary, so the
669        // byte-based `strip_suffix` removes exactly the same substring Python's
670        // char-based slice would — safe for "åtte"/"første" and friends.
671        for (key, val) in self.ords_pl.iter() {
672            if let Some(stem) = outword.strip_suffix(*key) {
673                outword = format!("{}{}", stem, val);
674                break;
675            }
676        }
677        // Second pass is NOT guarded by whether the first one fired.
678        for (key, val) in self.ords_sg.iter() {
679            if let Some(stem) = outword.strip_suffix(*key) {
680                outword = format!("{}{}", stem, val);
681                break;
682            }
683        }
684        Ok(outword)
685    }
686
687    /// Port of `Num2Word_NO.to_ordinal_num`: `str(value) + "."`.
688    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
689        self.verify_ordinal(value)?;
690        Ok(format!("{}.", value))
691    }
692
693    /// Port of `Num2Word_NO.to_year` (`longval` defaults to True).
694    ///
695    /// `if not (val // 100) % 10` → plain cardinal when the hundreds digit is
696    /// zero (1000, 2000, 2024, 10000, and everything under 100). Otherwise the
697    /// "<n> hundre og <n>" form via `to_splitnum`. Both `//` and `%` are
698    /// Python's flooring operators — `div_floor`/`mod_floor`, not `/` and `%`.
699    fn to_year(&self, value: &BigInt) -> Result<String> {
700        let hundred = BigInt::from(100);
701        let ten = BigInt::from(10);
702        if value.div_floor(&hundred).mod_floor(&ten).is_zero() {
703            return self.to_cardinal(value);
704        }
705        self.to_splitnum(value, "hundre", "", "og", &hundred, true, true)
706    }
707
708    // ---- float/Decimal entries -------------------------------------------
709    //
710    // Python's dispatcher hands floats/Decimals straight to the converter
711    // methods, so `verify_ordinal`'s float checks and `to_year`'s float
712    // branches become reachable here. `to_cardinal` needs no override: NO
713    // inherits base's `assert int(value) == value` routing, which is exactly
714    // the trait default (whole -> int path, fractional -> float grammar).
715
716    /// `to_ordinal(float/Decimal)`: verify_ordinal, then the integer path.
717    /// Whole values ordinalise (5.0 -> "femte", Decimal("1E+2") ->
718    /// "ett hundrede", -0.0 -> "null"); fractional or negative values raise
719    /// TypeError (module bug 10). The cardinal of a whole float equals the
720    /// cardinal of its integer, so delegating to the BigInt `to_ordinal`
721    /// (cardinal + ords_pl/ords_sg suffix scan) is exact.
722    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
723        let i = self.verify_ordinal_float(value)?;
724        self.to_ordinal(&i)
725    }
726
727    /// `to_ordinal_num(float/Decimal)`: verify_ordinal, then `str(value) +
728    /// "."` — the repr survives verbatim (module bug 11): 5.0 -> "5.0.",
729    /// Decimal("5.00") -> "5.00.", 1e16 -> "1e+16.", -0.0 -> "-0.0.".
730    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
731        self.verify_ordinal_float(value)?;
732        Ok(format!("{}.", repr_str))
733    }
734
735    /// `Num2Word_NO.to_year(float/Decimal)` — see module bugs 8 and 9.
736    ///
737    /// ```python
738    /// if not (val // 100) % 10: return self.to_cardinal(val)
739    /// return self.to_splitnum(val, hightxt="hundre", jointxt="og",
740    ///                         longval=longval)
741    /// ```
742    ///
743    /// The guard's `//` floors for floats but truncates for Decimals, and
744    /// `to_splitnum`'s float branch is `float2tuple` (int part / fractional
745    /// digits) while its Decimal branch is `divmod(val, 100)` — four distinct
746    /// behaviours from two lines of Python, all corpus-pinned:
747    /// 100.0 -> "ett hundre hundre", Decimal("100") -> "en hundre",
748    /// -1.5 -> "minus en hundre og fem", Decimal("-3.0") -> "minus tre".
749    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
750        match value {
751            FloatValue::Float { value: f, .. } => {
752                // `(val // 100) % 10` with CPython float_divmod semantics.
753                // NaN/inf never reach this entry (the dispatcher keeps them
754                // on the Python side), so the guard is finite arithmetic.
755                let guard = py_f64_mod(py_f64_floordiv(*f, 100.0), 10.0);
756                if guard == 0.0 {
757                    // Falsy guard (0.0 or -0.0): plain to_cardinal(val).
758                    return self.cardinal_float_entry(value, None);
759                }
760                // to_splitnum float branch: high/low from float2tuple, NOT
761                // divmod by 100 (module bug 8). hightxt "hundre" is appended
762                // whenever high is nonzero (inflect has no "/" to split on);
763                // jointxt "og" only joins when low is nonzero; lowtxt is
764                // empty and cents=True.
765                let (high, low) = float2tuple(value);
766                let mut out: Vec<String> = Vec::new();
767                if !high.is_zero() {
768                    out.push(self.to_cardinal(&high)?);
769                    out.push("hundre".to_string());
770                    if !low.is_zero() {
771                        out.push("og".to_string());
772                    }
773                }
774                if !low.is_zero() {
775                    out.push(self.to_cardinal(&low)?);
776                }
777                Ok(out.join(" "))
778            }
779            FloatValue::Decimal { value: d, .. } => {
780                // Decimal `//` truncates toward zero and `%` keeps the
781                // dividend's sign, so the guard reduces to: trunc(d / 100)
782                // not divisible by 10. Truncation commutes with the integer
783                // divisor, so trunc(d) / 100 (BigInt `/`, truncating) is
784                // exact.
785                let pre = d.with_scale(0).as_bigint_and_exponent().0;
786                let q: BigInt = &pre / BigInt::from(100);
787                if (&q % BigInt::from(10)).is_zero() {
788                    return self.cardinal_float_entry(value, None);
789                }
790                // to_splitnum Decimal branch: `high, low = val` raises
791                // TypeError (Decimal is not iterable), caught -> divmod(val,
792                // 100): truncated quotient, remainder with the dividend's
793                // sign and the dividend's scale (45.000 keeps exponent -3).
794                let low = d.clone() - BigDecimal::from(&q * BigInt::from(100));
795                let mut out: Vec<String> = Vec::new();
796                if !q.is_zero() {
797                    out.push(self.to_cardinal(&q)?);
798                    out.push("hundre".to_string());
799                    if !low.is_zero() {
800                        out.push("og".to_string());
801                    }
802                }
803                if !low.is_zero() {
804                    // self.to_cardinal(Decimal): whole -> int path
805                    // ("førtifem"), fractional -> float grammar with the
806                    // remainder's own scale as precision.
807                    let (_, exp) = low.as_bigint_and_exponent();
808                    let fv = FloatValue::Decimal {
809                        value: low.clone(),
810                        precision: exp.unsigned_abs() as u32,
811                    };
812                    out.push(self.cardinal_float_entry(&fv, None)?);
813                }
814                Ok(out.join(" "))
815            }
816        }
817    }
818
819    // ---- currency ------------------------------------------------------
820
821    /// The registry key is "nb", but the class behind it is `Num2Word_NO` —
822    /// there is no `Num2Word_NB`. The NotImplementedError message interpolates
823    /// `self.__class__.__name__`, so it must say "Num2Word_NO". Pinned against
824    /// the live interpreter:
825    /// `Currency code "GBP" not implemented for "Num2Word_NO"`.
826    fn lang_name(&self) -> &str {
827        "Num2Word_NO"
828    }
829
830    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
831        self.currency_forms.get(code)
832    }
833
834    fn currency_adjective(&self, code: &str) -> Option<&str> {
835        self.currency_adjectives.get(code).copied()
836    }
837
838    // `currency_precision` is intentionally NOT overridden: CURRENCY_PRECISION
839    // is Base's empty dict, so `.get(code, 100)` is always 100 — exactly the
840    // trait default. See the module-level "Currency surface" note.
841
842    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
843    ///
844    /// Python indexes the tuple directly, so a single-form entry with `n != 1`
845    /// would raise IndexError. All three of NO's entries carry two forms, so
846    /// that is unreachable — but it is mapped to `Index` rather than panicking
847    /// so the exception *type* survives if the table ever changes.
848    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
849        let form = if n.is_one() { 0 } else { 1 };
850        forms
851            .get(form)
852            .cloned()
853            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
854    }
855
856    /// Port of `Num2Word_NO.to_currency`: delegate to Base, then strip a
857    /// zero-øre tail.
858    ///
859    /// ```python
860    /// result = super().to_currency(val, currency=currency, cents=cents,
861    ///                              separator=separator, adjective=adjective)
862    /// result = result.replace(" og null øre", "")   # do not print "og null øre"
863    /// return result
864    /// ```
865    ///
866    /// The literal bakes in the default separator and the NOK subunit, so it is
867    /// both separator- and currency-blind; see module bug (6). Nothing else is
868    /// touched — a raised NotImplementedError propagates before the strip, so an
869    /// unknown code still errors rather than returning a stripped string.
870    fn to_currency(
871        &self,
872        val: &CurrencyValue,
873        currency: &str,
874        cents: bool,
875        separator: Option<&str>,
876        adjective: bool,
877    ) -> Result<String> {
878        // The trait hands us None when the caller omitted `separator=`;
879        // resolve it through this language's own default (" og") to reproduce
880        // Python's per-method default before the ported body runs.
881        let separator = separator.unwrap_or(self.default_separator());
882
883        let result = crate::currency::default_to_currency(
884            self, val, currency, cents, separator, adjective,
885        )?;
886
887        // Python's str.replace is unbounded — every occurrence, not just the
888        // first. Rust's str::replace matches that.
889        Ok(result.replace(" og null \u{f8}re", ""))
890    }
891}