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}