Skip to main content

num2words2_core/
lang_nl.rs

1//! Port of `lang_NL.py` (via `lang_EUR` → `Num2Word_Base`).
2//!
3//! Engine-style: NL supplies cards + `merge` and lets the base `to_cardinal`
4//! drive `splitnum`/`clean`.
5//!
6//! NL keeps EUR's long-scale `set_high_numwords` (step -6, pairing an
7//! `iljard` at 10^n with an `iljoen` at 10^(n-3)) and EUR's
8//! `gen_high_numwords`, but replaces the Latin stems with Dutch-flavoured
9//! ones ("dez"/"okto"/"tre"/"quin"/"sept" instead of
10//! "dec"/"octo"/"tres"/"quint"/"septen") and swaps the "cent" head for
11//! "zend". Only five of EUR's eighteen elision rules can still fire against
12//! the NL stems (novemn, novemo, sexn, sexs, unno) — the rest are dead code
13//! here, but the list is ported whole because Python applies it whole.
14
15use crate::base::{
16    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result,
17};
18use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
19use crate::floatpath::{default_to_cardinal_float, float2tuple, FloatValue};
20use crate::strnum::python_decimal_str;
21use bigdecimal::BigDecimal;
22use num_bigint::BigInt;
23use num_integer::Integer;
24use num_traits::{FromPrimitive, One, Signed, Zero};
25use std::collections::HashMap;
26
27/// Port of `Num2Word_EUR.gen_high_numwords`.
28///
29/// Kept private to this module: `lang_en.rs` exports an identical function,
30/// but the porting contract is one self-contained file per language.
31fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
32    let mut out: Vec<String> = Vec::new();
33    // Python: [u + t for t in tens for u in units] — tens is the outer loop.
34    for t in tens {
35        for u in units {
36            out.push(format!("{}{}", u, t));
37        }
38    }
39    out.reverse();
40
41    const REPLACEMENTS: &[(&str, &str)] = &[
42        ("novemn", "noven"),
43        ("novemo", "novo"),
44        ("octoo", "octo"),
45        ("quintd", "quind"),
46        ("quintn", "quin"),
47        ("quintq", "quinq"),
48        ("quints", "quins"),
49        ("quintt", "quint"),
50        ("quintv", "quinv"),
51        ("septenn", "septen"),
52        ("septent", "sept"),
53        ("sexn", "sen"),
54        ("sexs", "ses"),
55        ("tresd", "tred"),
56        ("tresn", "tren"),
57        ("tress", "tres"),
58        ("tresv", "trev"),
59        ("unno", "uno"),
60    ];
61    for &(k, v) in REPLACEMENTS {
62        out = out.iter().map(|o| o.replace(k, v)).collect();
63    }
64    out.extend(lows.iter().map(|s| s.to_string()));
65    out
66}
67
68/// `self.ords`, in Python dict insertion order.
69///
70/// `to_ordinal` scans this with `break` on the first suffix hit, so the order
71/// is load-bearing: "erd" must precede "rd" ("honderd" → "honderdst", not
72/// "hondeerdst"), and the spelled-out digits must precede the generic "ig" /
73/// "end" / "joen" suffixes.
74const ORDS: &[(&str, &str)] = &[
75    ("nul", "nuld"),
76    ("één", "eerst"),
77    ("twee", "tweed"),
78    ("drie", "derd"),
79    ("vier", "vierd"),
80    ("vijf", "vijfd"),
81    ("zes", "zesd"),
82    ("zeven", "zevend"),
83    ("acht", "achtst"),
84    ("negen", "negend"),
85    ("tien", "tiend"),
86    ("elf", "elfd"),
87    ("twaalf", "twaalfd"),
88    ("ig", "igst"),
89    ("erd", "erdst"),
90    ("end", "endst"),
91    ("joen", "joenst"),
92    ("rd", "rdst"),
93];
94
95/// `Num2Word_NL.CURRENCY_FORMS`.
96///
97/// Unlike the 16 classes that read `Num2Word_EUR`'s dict after
98/// `Num2Word_EN.__init__` has mutated it in place, NL declares
99/// `CURRENCY_FORMS` in its **own** class body. That shadows EUR's attribute
100/// outright, so NL never sees either EUR's 22 entries or the ~24 codes EN
101/// grafts on — the live interpreter confirms `Num2Word_NL.CURRENCY_FORMS is
102/// Num2Word_EUR.CURRENCY_FORMS` is False and holds exactly these four keys.
103///
104/// Hence JPY, KWD, BHD, INR and CHF all raise NotImplementedError for NL,
105/// which the corpus asserts row for row. Adding them "for completeness" would
106/// break 60 cases.
107///
108/// The subunit arity is load-bearing. `Num2Word_NL.pluralize` returns
109/// `forms[0]` unconditionally, so the ordinary cents path never reaches the
110/// second form — but `base.to_currency`'s fractional-cents branch takes
111/// `cr2[1]` *directly*, bypassing `pluralize` entirely. That splits GBP and
112/// CNY across the two paths:
113///
114/// ```text
115/// to_currency(12.34, "GBP") -> "twaalf pond en vierendertig penny"
116/// to_currency(1.011, "GBP") -> "één pond en één komma één pence"
117/// to_currency(1.011, "CNY") -> "één yuan en één komma één fen"
118/// ```
119///
120/// So dropping "pence"/"fen" as apparently-dead plurals would silently change
121/// output on any value with fractional subunits. All three verified against
122/// the live interpreter.
123fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
124    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
125    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "cent"]));
126    m.insert("GBP", CurrencyForms::new(&["pond", "pond"], &["penny", "pence"]));
127    m.insert(
128        "USD",
129        CurrencyForms::new(&["dollar", "dollar"], &["cent", "cent"]),
130    );
131    m.insert("CNY", CurrencyForms::new(&["yuan", "yuan"], &["jiao", "fen"]));
132    m
133}
134
135/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged — NL declares no
136/// adjectives of its own, and EN mutates only `CURRENCY_FORMS`, never this.
137///
138/// Nearly all of it is unreachable: `base.to_currency` looks up
139/// `CURRENCY_FORMS` *before* consulting the adjectives, so any code outside
140/// NL's four raises NotImplementedError first. Of those four only USD has an
141/// entry, making `to_currency(12.34, "USD", adjective=True)` -> "twaalf US
142/// dollar en vierendertig cent" the single reachable case (verified against
143/// the live interpreter). The full table is kept because it is the inherited
144/// data, exactly as `lang_en.rs` keeps it.
145fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
146    [
147        ("AUD", "Australian"),
148        ("BYN", "Belarusian"),
149        ("CAD", "Canadian"),
150        ("EEK", "Estonian"),
151        ("USD", "US"),
152        ("RUB", "Russian"),
153        ("NOK", "Norwegian"),
154        ("MXN", "Mexican"),
155        ("RON", "Romanian"),
156        ("INR", "Indian"),
157        ("HUF", "Hungarian"),
158        ("ISK", "íslenskar"),
159        ("UZS", "Uzbekistan"),
160        ("SAR", "Saudi"),
161        ("JPY", "Japanese"),
162        ("KRW", "Korean"),
163    ]
164    .into_iter()
165    .collect()
166}
167
168/// `abs(Decimal(repr(f)).as_tuple().exponent)` for an f64 — the fractional
169/// digit count of the shortest round-trip repr. Mirrors the private helper in
170/// `floatpath`.
171fn float_repr_precision(f: f64) -> u32 {
172    let s = format!("{}", f);
173    match s.split_once('.') {
174        Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
175        _ => 0,
176    }
177}
178
179/// Best-effort `str(number)` for the TypeError messages (the corpus compares
180/// exception types only, so the float arm's repr need not be byte-exact).
181fn py_num_str(value: &FloatValue) -> String {
182    match value {
183        FloatValue::Float { value, .. } => format!("{}", value),
184        FloatValue::Decimal { value, .. } => python_decimal_str(value),
185    }
186}
187
188pub struct LangNl {
189    cards: Cards,
190    maxval: BigInt,
191    currency_forms: HashMap<&'static str, CurrencyForms>,
192    currency_adjectives: HashMap<&'static str, &'static str>,
193}
194
195impl Default for LangNl {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201impl LangNl {
202    pub fn new() -> Self {
203        let lows = ["non", "okt", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
204        let units = [
205            "", "un", "duo", "tre", "quattuor", "quin", "sex", "sept", "okto", "novem",
206        ];
207        let tens = [
208            "dez",
209            "vigint",
210            "trigint",
211            "quadragint",
212            "quinquagint",
213            "sexagint",
214            "septuagint",
215            "oktogint",
216            "nonagint",
217        ];
218        // NL.setup() calls super().setup() (which sets high_numwords with the
219        // "cent" head) and then overwrites high_numwords outright, so only
220        // this assignment survives.
221        let mut high = vec!["zend".to_string()];
222        high.extend(gen_high_numwords(&units, &tens, &lows));
223
224        let mut cards = Cards::new();
225
226        // EUR.set_high_numwords: cap = 3 + 6*len(high); zip(high, range(cap, 3, -6)).
227        // GIGA_SUFFIX = "iljard", MEGA_SUFFIX = "iljoen" — both non-empty for
228        // NL, so every step inserts a pair. len(high) is 100 and the range
229        // yields exactly 100 values (603 down to 9), so zip truncates neither.
230        let cap = 3 + 6 * high.len() as i64;
231        let mut n = cap;
232        for word in high.iter() {
233            if n <= 3 {
234                break;
235            }
236            cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}iljard", word));
237            cards.insert(
238                BigInt::from(10u8).pow((n - 3) as u32),
239                format!("{}iljoen", word),
240            );
241            n -= 6;
242        }
243
244        set_mid_numwords(
245            &mut cards,
246            &[
247                (1000, "duizend"),
248                (100, "honderd"),
249                (90, "negentig"),
250                (80, "tachtig"),
251                (70, "zeventig"),
252                (60, "zestig"),
253                (50, "vijftig"),
254                (40, "veertig"),
255                (30, "dertig"),
256            ],
257        );
258        set_low_numwords(
259            &mut cards,
260            &[
261                "twintig",
262                "negentien",
263                "achttien",
264                "zeventien",
265                "zestien",
266                "vijftien",
267                "veertien",
268                "dertien",
269                "twaalf",
270                "elf",
271                "tien",
272                "negen",
273                "acht",
274                "zeven",
275                "zes",
276                "vijf",
277                "vier",
278                "drie",
279                "twee",
280                "één",
281                "nul",
282            ],
283        );
284
285        // MAXVAL = 1000 * first card key. Python's OrderedDict is filled
286        // high-first, so key[0] is 10^603 — the same as the max key.
287        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
288
289        LangNl {
290            cards,
291            maxval,
292            // Built once here, never per call: `to_currency` only reads these,
293            // and rebuilding them per call is what made an earlier revision of
294            // this port slower than the Python it replaces.
295            currency_forms: build_currency_forms(),
296            currency_adjectives: build_currency_adjectives(),
297        }
298    }
299
300    /// `Num2Word_Base.verify_ordinal` for a float/Decimal: the float check
301    /// (`errmsg_floatord`) fires first, then the negative one — both with
302    /// NL's Dutch wording. `-0.0` passes both (`abs(-0.0) == -0.0`).
303    /// Returns the whole value on success.
304    fn verify_ordinal_num(&self, value: &FloatValue) -> Result<BigInt> {
305        let whole = match value.as_whole_int() {
306            Some(i) => i,
307            None => {
308                return Err(N2WError::Type(format!(
309                    "Het zwevende puntnummer {} kan niet omgezet worden naar een ordernummer.",
310                    py_num_str(value)
311                )))
312            }
313        };
314        if whole.is_negative() {
315            return Err(N2WError::Type(format!(
316                "Het negatieve getal {} kan niet omgezet worden naar een ordernummer.",
317                py_num_str(value)
318            )));
319        }
320        Ok(whole)
321    }
322
323    /// Python's `self.to_cardinal(x)` for an f64 — Base semantics: whole →
324    /// integer path, fractional → the base float grammar ("komma" + digits).
325    fn cardinal_num_f64(&self, x: f64) -> Result<String> {
326        if x.fract() == 0.0 {
327            if let Some(i) = BigInt::from_f64(x) {
328                return self.to_cardinal(&i);
329            }
330        }
331        default_to_cardinal_float(
332            self,
333            &FloatValue::Float {
334                value: x,
335                precision: float_repr_precision(x),
336            },
337            None,
338        )
339    }
340
341    /// Python's `self.to_cardinal(x)` for a Decimal.
342    fn cardinal_num_dec(&self, x: &BigDecimal) -> Result<String> {
343        if x.is_integer() {
344            return self.to_cardinal(&x.with_scale(0).as_bigint_and_exponent().0);
345        }
346        let precision = x.as_bigint_and_exponent().1.max(0) as u32;
347        default_to_cardinal_float(
348            self,
349            &FloatValue::Decimal {
350                value: x.clone(),
351                precision,
352            },
353            None,
354        )
355    }
356
357    /// `Num2Word_Base.verify_ordinal` with NL's `errmsg_negord`.
358    /// The float branch is out of scope (integer input only).
359    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
360        if value.is_negative() {
361            return Err(N2WError::Type(format!(
362                "Het negatieve getal {} kan niet omgezet worden naar een ordernummer.",
363                value
364            )));
365        }
366        Ok(())
367    }
368
369    /// `Num2Word_Base.to_splitnum` specialised to NL's only call site:
370    /// `hightxt="honderd"`, `lowtxt=""`, `jointxt=""`, `divisor=100`,
371    /// `longval=True`, `cents=True`.
372    ///
373    /// `inflect(high, "honderd")` returns "honderd" for every `high`
374    /// ("honderd".split("/") has one element, so both branches agree), and
375    /// `title` is the identity because NL leaves `is_title` False.
376    fn to_splitnum(&self, val: &BigInt) -> Result<String> {
377        // Python's divmod is floor-based; this matters for negative years,
378        // e.g. divmod(-44, 100) == (-1, 56).
379        let (high, low) = val.div_mod_floor(&BigInt::from(100));
380        let mut out: Vec<String> = Vec::new();
381
382        if !high.is_zero() {
383            out.push(self.to_cardinal(&high)?);
384            if !low.is_zero() {
385                // longval is True and hightxt is non-empty; jointxt is ""
386                // so the join segment is skipped.
387                out.push("honderd".to_string());
388            } else {
389                out.push("honderd".to_string());
390            }
391        }
392
393        if !low.is_zero() {
394            // cents=True, so the low part is spelled out rather than "%02d".
395            out.push(self.to_cardinal(&low)?);
396        }
397
398        Ok(out.join(" "))
399    }
400}
401
402impl Lang for LangNl {
403    /// This language's own `to_currency(currency=...)` default,
404    /// read from the live Python signature. Only 44 of 156 use EUR.
405    fn default_currency(&self) -> &str {
406        "EUR"
407    }
408
409    /// This language's own `to_currency(separator=...)` default,
410    /// read from the live Python signature. Base's is ",", but only
411    /// 36 of 149 languages actually use it — most default to " " or a
412    /// conjunction, so inheriting Base's comma silently corrupts them.
413    fn default_separator(&self) -> &str {
414        " en"
415    }
416
417    fn cards(&self) -> &Cards {
418        &self.cards
419    }
420    fn maxval(&self) -> &BigInt {
421        &self.maxval
422    }
423    fn negword(&self) -> &str {
424        "min "
425    }
426    fn pointword(&self) -> &str {
427        "komma"
428    }
429
430    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
431        // NL overrides errmsg_toobig, so the overflow check is done here with
432        // NL's wording rather than leaving it to `default_to_cardinal`'s
433        // base-class message. The predicate is identical, so the base check
434        // can never fire after this one passes.
435        let v = value.abs();
436        if &v >= self.maxval() {
437            return Err(N2WError::Overflow(format!(
438                "Het getal {} moet minder zijn dan {}.",
439                v,
440                self.maxval()
441            )));
442        }
443        default_to_cardinal(self, value)
444    }
445
446    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
447        let (ctext0, cnum) = l;
448        let (ntext0, nnum) = r;
449        let mut ctext = ctext0.to_string();
450        let mut ntext = ntext0.to_string();
451
452        let ten = BigInt::from(10);
453        let hundred = BigInt::from(100);
454        let thousand = BigInt::from(1000);
455        let million = BigInt::from(1_000_000);
456
457        if cnum.is_one() {
458            if nnum < &million {
459                return (ntext, nnum.clone());
460            }
461            // "één miljoen" would be wrong; the multiplier drops its accents.
462            ctext = "een".to_string();
463        }
464
465        let val;
466        if nnum > cnum {
467            if nnum >= &million {
468                ctext.push(' ');
469            }
470            val = cnum * nnum;
471        } else {
472            // Python's chained `nnum < 10 < cnum < 100`.
473            if nnum < &ten && ten < *cnum && cnum < &hundred {
474                if nnum.is_one() {
475                    ntext = "een".to_string();
476                }
477                if ntext.ends_with('e') {
478                    ntext.push_str("ën");
479                } else {
480                    ntext.push_str("en");
481                }
482                // Python: `ntext, ctext = ctext, ntext` — the unit word moves
483                // in front of the ten ("eenentwintig").
484                std::mem::swap(&mut ntext, &mut ctext);
485            } else if cnum >= &thousand {
486                ctext.push(' ');
487            }
488            val = cnum + nnum;
489        }
490
491        (format!("{}{}", ctext, ntext), val)
492    }
493
494    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
495        self.verify_ordinal(value)?;
496        let mut outword = self.to_cardinal(value)?;
497        for &(key, rep) in ORDS {
498            // Python slices `outword[:len(outword) - len(key)]` by character
499            // count. Because the suffix matched, that split point is exactly
500            // where `strip_suffix` cuts — no byte/char discrepancy survives
501            // (this matters for "één": 3 chars, 5 bytes).
502            if let Some(head) = outword.strip_suffix(key) {
503                outword = format!("{}{}", head, rep);
504                break;
505            }
506        }
507        Ok(outword + "e")
508    }
509
510    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
511        self.verify_ordinal(value)?;
512        Ok(format!("{}e", value))
513    }
514
515    fn to_year(&self, value: &BigInt) -> Result<String> {
516        let hundred = BigInt::from(100);
517
518        // Years shaped "[N]honderd[M]" (1100..9999), written without spaces —
519        // except the 2000..2099 window, which keeps the "tweeduizend X" form.
520        if *value >= BigInt::from(1100)
521            && *value <= BigInt::from(9999)
522            && !(*value >= BigInt::from(2000) && *value <= BigInt::from(2099))
523        {
524            let hundreds = value / &hundred;
525            let remainder = value % &hundred;
526            let mut result = self.to_cardinal(&hundreds)?.replace(' ', "") + "honderd";
527            if !remainder.is_zero() {
528                result.push_str(&self.to_cardinal(&remainder)?.replace(' ', ""));
529            }
530            return Ok(result);
531        }
532
533        // `if not (val // 100) % 10` — Python floor-division and Python
534        // modulo (result takes the divisor's sign), hence div_floor/mod_floor.
535        // For val = -44 this is (-1) % 10 == 9, which is truthy, so negative
536        // years fall through to the to_splitnum branch.
537        if (value.div_floor(&hundred))
538            .mod_floor(&BigInt::from(10))
539            .is_zero()
540        {
541            return self.to_cardinal(value);
542        }
543
544        self.to_splitnum(value)
545    }
546
547    /// `to_ordinal(float/Decimal)`: `verify_ordinal` raises TypeError for any
548    /// fractional or negative value; a whole non-negative one flows through
549    /// `to_cardinal` + the `ords` suffix table — identical to the integer
550    /// ordinal ("5.0" → "vijfde", `1e+16` → "tien biljardste").
551    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
552        let whole = self.verify_ordinal_num(value)?;
553        self.to_ordinal(&whole)
554    }
555
556    /// `to_ordinal_num(float/Decimal)`: `verify_ordinal` then
557    /// `str(value) + "e"` — "5.0e", "1E+2e", "-0.0e".
558    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
559        self.verify_ordinal_num(value)?;
560        Ok(format!("{}e", repr_str))
561    }
562
563    /// `to_year(float/Decimal)` — `Num2Word_NL.to_year`'s arithmetic run on
564    /// the raw float/Decimal, quirks and all:
565    ///
566    /// * 1100..=9999 (minus the 2000..2099 window): "[N]honderd[M]" from
567    ///   `val // 100` and `val % 100` — `1234.0` → "twaalfhonderdvierendertig".
568    /// * hundreds digit zero (`not (val // 100) % 10`): plain cardinal.
569    /// * otherwise `to_splitnum(val, hightxt="honderd")`, whose **float** arm
570    ///   goes through `float2tuple` — so `100.0` splits into `(100, 0)` and
571    ///   reads "honderd honderd", and `-21.0` into `(-21, 0)` →
572    ///   "min eenentwintig honderd" — while the **Decimal** arm divmods by
573    ///   100: `Decimal("100")` → "één honderd",
574    ///   `Decimal("12345.000")` → "honderddrieëntwintig honderd vijfenveertig".
575    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
576        match value {
577            FloatValue::Float { value: v, .. } => {
578                let v = *v;
579                if (1100.0..=9999.0).contains(&v) && !(2000.0..=2099.0).contains(&v) {
580                    let hundreds = (v / 100.0).floor();
581                    let remainder = v.rem_euclid(100.0);
582                    let mut result =
583                        format!("{}honderd", self.cardinal_num_f64(hundreds)?.replace(' ', ""));
584                    if remainder != 0.0 {
585                        result.push_str(&self.cardinal_num_f64(remainder)?.replace(' ', ""));
586                    }
587                    return Ok(result);
588                }
589                if (v / 100.0).floor().rem_euclid(10.0) == 0.0 {
590                    return self.cardinal_float_entry(value, None);
591                }
592                // to_splitnum, float arm: high/low from base.float2tuple —
593                // int() truncation for `pre`, repr digits for `post`.
594                let (high, low) = float2tuple(value);
595                let mut out: Vec<String> = Vec::new();
596                if !high.is_zero() {
597                    out.push(self.to_cardinal(&high)?);
598                    out.push("honderd".to_string());
599                }
600                if !low.is_zero() {
601                    out.push(self.to_cardinal(&low)?);
602                }
603                Ok(out.join(" "))
604            }
605            FloatValue::Decimal { value: d, .. } => {
606                let hundred = BigDecimal::from(100);
607                let lo = BigDecimal::from(1100);
608                let hi = BigDecimal::from(9999);
609                let w_lo = BigDecimal::from(2000);
610                let w_hi = BigDecimal::from(2099);
611                if d >= &lo && d <= &hi && !(d >= &w_lo && d <= &w_hi) {
612                    // Decimal // and % truncate toward zero; positive here.
613                    let hundreds = (d / &hundred).with_scale_round(
614                        0,
615                        bigdecimal::RoundingMode::Down,
616                    );
617                    let remainder = d - &hundreds * &hundred;
618                    let mut result = format!(
619                        "{}honderd",
620                        self.cardinal_num_dec(&hundreds)?.replace(' ', "")
621                    );
622                    if !remainder.is_zero() {
623                        result.push_str(&self.cardinal_num_dec(&remainder)?.replace(' ', ""));
624                    }
625                    return Ok(result);
626                }
627                let q = (d / &hundred).with_scale_round(0, bigdecimal::RoundingMode::Down);
628                let q_int = q.as_bigint_and_exponent().0;
629                // Python `%` on the int-valued Decimal quotient: sign of the
630                // dividend (truncated), matching Rust's BigInt `%`.
631                if (&q_int % BigInt::from(10)).is_zero() {
632                    return self.cardinal_float_entry(value, None);
633                }
634                // to_splitnum, Decimal arm: `high, low = divmod(val, 100)`.
635                let high = q;
636                let low = d - &high * &hundred;
637                let mut out: Vec<String> = Vec::new();
638                if !high.is_zero() {
639                    out.push(self.cardinal_num_dec(&high)?);
640                    out.push("honderd".to_string());
641                }
642                if !low.is_zero() {
643                    out.push(self.cardinal_num_dec(&low)?);
644                }
645                Ok(out.join(" "))
646            }
647        }
648    }
649
650    // ---- currency -------------------------------------------------------
651    //
652    // NL overrides only `to_currency` and `pluralize`; `to_cheque`,
653    // `_money_verbose`, `_cents_verbose` and `_cents_terse` all come from
654    // `Num2Word_Base` unchanged, and `CURRENCY_PRECISION` stays base's empty
655    // dict (EN *rebinds* it on the instance rather than mutating the class
656    // attribute, so its 3-decimal entries never leak here). Every code is
657    // therefore divisor 100 and the trait defaults already match — hence no
658    // `currency_precision`, `money_verbose` or `to_cheque` override below.
659
660    fn lang_name(&self) -> &str {
661        "Num2Word_NL"
662    }
663
664    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
665        self.currency_forms.get(code)
666    }
667
668    fn currency_adjective(&self, code: &str) -> Option<&str> {
669        self.currency_adjectives.get(code).copied()
670    }
671
672    /// `Num2Word_NL.pluralize`: `return forms[0]` — the plural form is *never*
673    /// selected, overriding `Num2Word_EUR`'s `forms[0 if n == 1 else 1]`.
674    ///
675    /// The docstring cites onzetaal.nl on euro/euro's and admits uncertainty
676    /// about the scope of the rule ("not sure if it's applied only to euro").
677    /// Its practical effect is that GBP reads "vierendertig penny" rather than
678    /// "...pence", and CNY "vierendertig jiao" rather than "...fen". The
679    /// corpus pins both of those singular readings, so this is behaviour to
680    /// preserve, not a wrinkle to fix. (The plurals are still reachable via
681    /// the fractional-cents branch, which skips this method — see
682    /// `build_currency_forms`.)
683    ///
684    /// Python indexes the tuple directly, so an empty entry would raise
685    /// IndexError. Unreachable for NL's table — mapped rather than panicking
686    /// so the exception type survives if the data ever changes.
687    fn pluralize(&self, _n: &BigInt, forms: &[String]) -> Result<String> {
688        forms
689            .first()
690            .cloned()
691            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
692    }
693
694    /// `Num2Word_NL.to_currency`.
695    ///
696    /// NL intercepts the `isinstance(val, int)` case with its own body and
697    /// delegates everything else to `Num2Word_Base.to_currency`. The int arm
698    /// diverges from base's in three ways, all of them bugs kept for parity:
699    ///
700    /// 1. `minus_str = self.negword` keeps negword's **trailing space** where
701    ///    base does `"%s " % self.negword.strip()`. Feeding that to
702    ///    `"%s %s %s"` yields a doubled interior space: `to_currency(-1)` is
703    ///    `"min  één euro"`, not `"min één euro"`. `.strip()` only trims the
704    ///    ends, so the double space survives — as must `trim()` here. Verified
705    ///    against the live interpreter; no negative *int* row exists in the
706    ///    corpus, so this rests on that check alone.
707    /// 2. `adjective` is accepted and then **ignored** — the int arm never
708    ///    calls `prefix_currency`. So `to_currency(1, "USD", adjective=True)`
709    ///    is "één dollar" while the float `1.0` gives "één US dollar en nul
710    ///    cent".
711    /// 3. The arm inlines a plural rule (`cr1[0] if abs_val == 1 else cr1[1]`)
712    ///    that contradicts NL's own `pluralize` (always `forms[0]`). Harmless
713    ///    in practice: every NL unit tuple has cr1[0] == cr1[1].
714    ///
715    /// The `except (KeyError, AttributeError)` fallback re-enters base for an
716    /// unknown code, which raises NotImplementedError with NL's class name.
717    /// `AttributeError` is unreachable (`CURRENCY_FORMS` always exists), so
718    /// only the missing-key path is modelled.
719    fn to_currency(
720        &self,
721        val: &CurrencyValue,
722        currency: &str,
723        cents: bool,
724        separator: Option<&str>,
725        adjective: bool,
726    ) -> Result<String> {
727        // Trait hands us None when the caller omitted separator=; resolve it
728        // to NL's own default (" en") before the ported body runs.
729        let separator = separator.unwrap_or(self.default_separator());
730
731        if let CurrencyValue::Int(v) = val {
732            let forms = match self.currency_forms.get(currency) {
733                Some(f) => f,
734                // KeyError -> super().to_currency(...), which raises
735                // NotImplementedError from its own CURRENCY_FORMS lookup.
736                None => {
737                    return default_to_currency(self, val, currency, cents, separator, adjective)
738                }
739            };
740            let cr1 = &forms.unit;
741
742            let minus_str = if v.is_negative() { self.negword() } else { "" };
743            let abs_val = v.abs();
744            let money_str = self.to_cardinal(&abs_val)?;
745
746            let currency_str = if abs_val.is_one() {
747                &cr1[0]
748            } else if cr1.len() > 1 {
749                &cr1[1]
750            } else {
751                &cr1[0]
752            };
753
754            // Python: ("%s %s %s" % (...)).strip() — see bug 1 above; trim()
755            // must not collapse the interior double space on negatives.
756            return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
757                .trim()
758                .to_string());
759        }
760
761        // Floats/Decimals: `super(Num2Word_NL, self).to_currency(...)`.
762        default_to_currency(self, val, currency, cents, separator, adjective)
763    }
764}