Skip to main content

num2words2_core/
lang_da.rs

1//! Port of `lang_DA.py` (Danish), via its `lang_EUR` → `Num2Word_Base`
2//! ancestry. Registry key `"da"` resolves to `Num2Word_DA` (verified in
3//! `__init__.py` CONVERTER_CLASSES).
4//!
5//! Shape: **engine**. `Num2Word_DA.setup` supplies `mid_numwords` /
6//! `low_numwords` and inherits `Num2Word_EUR.set_high_numwords`, so
7//! `Num2Word_Base.to_cardinal` drives `splitnum`/`clean`/`merge`. Only
8//! `merge`, `to_ordinal`, `to_ordinal_num` and `to_year` are overridden.
9//!
10//! DA keeps EUR's long scale but pluralises both suffixes:
11//! `GIGA_SUFFIX = "illiarder"`, `MEGA_SUFFIX = "illioner"` — hence
12//! 10^6 → "millioner" (not "million") and 10^9 → "milliarder". `high` holds
13//! 100 stems, so `cap = 3 + 6*100 = 603`, the top card is 10^603
14//! ("centilliarder") and `MAXVAL = 1000 * 10^603 = 10^606`.
15//!
16//! # Cross-call mutable state — IMPORTANT for the dispatcher
17//!
18//! `Num2Word_DA` carries `self.ordflag`, set to `True` by `to_ordinal`,
19//! read by `merge`, and reset to `False` afterwards. It changes `merge`'s
20//! `cnum == 1` arm: with the flag set, a leading "et" is dropped even above
21//! 10^6, so `to_cardinal(10**6)` == "en millioner" but the cardinal computed
22//! *inside* `to_ordinal(10**6)` is just "millioner" → "millionerte".
23//!
24//! Two consequences:
25//!
26//! 1. The Rust path is stateless: [`DaOrd`] is a thin wrapper that runs the
27//!    same engine with `ordflag = true`, so `to_ordinal` reproduces the
28//!    handshake without a mutable field.
29//! 2. **Python leaks the flag on failure.** `to_ordinal` sets `ordflag = True`
30//!    *before* `to_cardinal` and only clears it *after* a successful return,
31//!    so an `OverflowError` from `to_cardinal(v >= 10**606)` leaves
32//!    `ordflag == True` on the shared singleton in `CONVERTER_CLASSES`. Every
33//!    later `to_cardinal` on that instance then silently drops the leading
34//!    "et"/"en" ("millioner" instead of "en millioner"). The Rust port cannot
35//!    reproduce a poisoned singleton and does not try to; a differential
36//!    harness must not compare Python `to_cardinal` output taken *after* an
37//!    overflowing `to_ordinal` on the same instance.
38//!
39//! # Faithfully reproduced Python bugs
40//!
41//! This is a port, not a rewrite. All of the following are verified against
42//! the frozen corpus:
43//!
44//! 1. **The "et" prefix is computed and then mostly thrown away.** `merge`
45//!    unpacks `ctext, cnum, ntext, nnum = curr + next` *first*, then rebinds
46//!    the local `next` to an "et"-prefixed copy. `ntext` still holds the
47//!    original word, and the final `word = ctext + ntext` uses `ntext`. So
48//!    the prefix only survives through the `cnum == 1 → return next` path:
49//!    100 → "ethundrede", but 200 → "to" + "hundrede" = "tohundrede", never
50//!    "toethundrede". Likewise 1100 builds a discarded "etethundrede".
51//! 2. **`merge` has no separator above 100000.** The `cnum >= 1000 and
52//!    cnum <= 100000` arm adds "e og ", but at 123000 the guard fails and
53//!    *nothing* is appended, so 123456 == "ethundrede og treogtyvetusind"
54//!    + "firehundrede og seksoghalvtreds" run together:
55//!    "ethundrede og treogtyvetusindfirehundrede og seksoghalvtreds".
56//! 3. **Plural millions.** `MEGA_SUFFIX`/`GIGA_SUFFIX` are plural, so
57//!    10^6 == "en millioner" — grammatically wrong Danish ("en million"),
58//!    kept verbatim.
59//! 4. **Ordinal suffixes double up.** `to_ordinal` first rewrites a trailing
60//!    cardinal via `ords`, *then* appends "te"/"ende" by `value % 100`, with
61//!    no check that a rewrite happened. 30 has no `ords` entry ("tredive"
62//!    matches no key) so it becomes "tredivete"; 40 → "fyrreende";
63//!    50 → "halvtredsende"; 100 → "ethundredete".
64//! 5. **The `ords` lookup is a suffix scan over a dict, so insertion order is
65//!    load-bearing.** `for key in self.ords: if outword.endswith(key): ...
66//!    break` takes the *first* insertion-ordered hit, not the longest. Ported
67//!    as an ordered `Vec` — a `HashMap` would be non-deterministic and wrong.
68//! 6. **`ords` values for the teens are truncated stems**, e.g.
69//!    "tretten" → "trett", relying on the later "ende" append to finish the
70//!    word ("trettende"). A teen that skipped the append would emit a stub.
71//! 7. **`to_ordinal`'s `or value % 100 == 0` in the `elif` is dead code** —
72//!    the `if` above already catches `% 100 == 0`. Kept for fidelity.
73//! 8. **`to_year` on negatives floor-divides into nonsense.** Python's `//`
74//!    and `%` floor toward -inf, so `to_year(-44)` takes
75//!    `divmod(-44, 100) == (-1, 56)` and emits
76//!    "minus et hundrede seksoghalvtreds". Reproduced with `div_mod_floor`;
77//!    `num-bigint`'s bare `/` and `%` truncate and would give the wrong
78//!    answer here.
79//! 9. **Negative integers get a double space.** `Num2Word_DA.to_currency`'s
80//!    integer arm takes `minus_str = self.negword` *raw* — trailing space
81//!    intact — and drops it into `"%s %s %s"`, so `to_currency(-12, "DKK")`
82//!    is "minus  tolv kroner" with two spaces. The trailing `.strip()` only
83//!    touches the ends, never the interior. Base would have said
84//!    "minus tolv kroner" (it uses `negword.strip() + " "`), and the float
85//!    arm — which delegates to Base — does exactly that: `-12.34` is
86//!    "minus tolv kroner, ...". So the spacing differs between `-12` and
87//!    `-12.0` in the same language. Verified against the live interpreter.
88//!    Note DA's template is `"%s %s %s"`, unlike `lang_DE.py`'s `"%s%s %s"`
89//!    — DE therefore does *not* have this bug and its port must not be
90//!    copied here.
91//! 10. **The integer arm silently ignores `adjective=`.** Only Base's float
92//!     arm consults `CURRENCY_ADJECTIVES`, so `to_currency(2, "USD",
93//!     adjective=True)` is "to dollars" while `to_currency(2.5, "USD",
94//!     adjective=True)` is "to US dollars, halvtreds cent".
95//! 11. **Floats/Decimals reach `to_ordinal`/`to_ordinal_num` through
96//!     `Num2Word_Base.verify_ordinal`** — `not value == int(value)` raises
97//!     TypeError(errmsg_floatord) for fractional input, then `not abs(value)
98//!     == value` raises TypeError(errmsg_negord) for negative *whole* input
99//!     (so `-1.5` gets the float message, `-1.0` the negative one). Both
100//!     comparisons are numeric: `-0.0` passes both and ordinalises to
101//!     "nulte" / "-0.0te". Whole values then continue on the integer path —
102//!     `value % 100` on a non-negative whole float equals the integer mod,
103//!     and `str(value)` (the dispatcher's repr) keeps its float/Decimal
104//!     spelling: `to_ordinal_num(2.0)` == "2.0en",
105//!     `to_ordinal_num(Decimal("5.00"))` == "5.00te".
106//! 12. **`to_year`'s guard `(val // 100) % 10` splits float from Decimal.**
107//!     Float `//` *floors* (CPython `float_divmod`), so `-21.0 // 100 ==
108//!     -1.0` and every negative float in (-10**3, 0) takes the splitnum
109//!     path; Decimal `//` *truncates* (`Decimal('-21.0') // 100 ==
110//!     Decimal('-0')`), so the same value as a Decimal falls through to
111//!     plain `to_cardinal`. Ported with `float_divmod` semantics on the f64
112//!     arm and truncating BigInt division on the Decimal arm.
113//! 13. **`to_splitnum` mangles floats.** Its float branch calls
114//!     `float2tuple(val)` — (integer part, fractional digits) — instead of
115//!     divmod by 100, so `to_year(100.0)` is "ethundrede hundrede" (high
116//!     part 100, not 1) while `to_year(Decimal('100'))` divmods to
117//!     "et hundrede"; `to_year(-1.5)` becomes "minus et hundrede fem"
118//!     (high = int(-1.5) = -1, low = 5). The Decimal branch divmods with
119//!     truncation, so `Decimal('12345.000')` splits into 123 / 45.000 —
120//!     "ethundrede og treogtyve hundrede femogfyrre". Reproduced verbatim.
121//!
122//! # `CURRENCY_FORMS`: DA is *not* caught by the `lang_EUR` mutation trap
123//!
124//! `Num2Word_EN.__init__` mutates `Num2Word_EUR.CURRENCY_FORMS` in place, and
125//! `__init__.py` instantiates `Num2Word_EN()` at import time, so every class
126//! that merely *inherits* that dict sees English's rewrites (EUR pluralised to
127//! "euros", ~24 extra codes added). `Num2Word_DA` defines its own
128//! `CURRENCY_FORMS` class attribute, which shadows EUR's entirely — confirmed
129//! at runtime with `Num2Word_DA.CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS`
130//! → `False`. So the six codes below are transcribed from the DA source
131//! literal, and EN's additions (JPY, KWD, BHD, INR, CNY, CHF, ...) are
132//! correctly absent → `NotImplementedError`, as the corpus requires.
133//!
134//! `CURRENCY_ADJECTIVES` is *not* redefined by DA, so it inherits EUR's
135//! (which EN never touches). `CURRENCY_PRECISION` is inherited from
136//! `Num2Word_Base` as `{}`, so every code resolves to the default divisor of
137//! 100 — DA has no 3-decimal or 0-decimal currency and Base's `divisor == 1`
138//! branch is unreachable here. Both are left to the trait defaults.
139
140use crate::base::{
141    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result,
142};
143use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
144use crate::floatpath::{float2tuple, FloatValue};
145use bigdecimal::BigDecimal;
146use num_bigint::BigInt;
147use num_integer::Integer;
148use num_traits::{One, Signed, Zero};
149use std::collections::HashMap;
150
151/// Port of `Num2Word_EUR.gen_high_numwords`.
152///
153/// Inlined rather than imported from `lang_en` to keep this file free of
154/// cross-language dependencies (the registry is generated mechanically).
155fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
156    // Python: [u + t for t in tens for u in units] — `tens` is the outer loop.
157    let mut out: Vec<String> = Vec::new();
158    for t in tens {
159        for u in units {
160            out.push(format!("{}{}", u, t));
161        }
162    }
163    out.reverse();
164
165    const REPLACEMENTS: &[(&str, &str)] = &[
166        ("novemn", "noven"),
167        ("novemo", "novo"),
168        ("octoo", "octo"),
169        ("quintd", "quind"),
170        ("quintn", "quin"),
171        ("quintq", "quinq"),
172        ("quints", "quins"),
173        ("quintt", "quint"),
174        ("quintv", "quinv"),
175        ("septenn", "septen"),
176        ("septent", "sept"),
177        ("sexn", "sen"),
178        ("sexs", "ses"),
179        ("tresd", "tred"),
180        ("tresn", "tren"),
181        ("tress", "tres"),
182        ("tresv", "trev"),
183        ("unno", "uno"),
184    ];
185    for (k, v) in REPLACEMENTS {
186        out = out.iter().map(|o| o.replace(k, v)).collect();
187    }
188    out.extend(lows.iter().map(|s| s.to_string()));
189    out
190}
191
192/// Python `str(float)`/`repr(float)`, used only to build error-message text
193/// (`errmsg_floatord`, `errmsg_negord`). The corpora compare exception
194/// *types*; the text follows the Python format strings. Whole floats keep
195/// their ".0" ("21.0"), |v| >= 1e16 switches to Python's exponent form
196/// ("1e+20"), and -0.0 keeps its sign.
197fn py_float_str(f: f64) -> String {
198    if f.is_nan() {
199        return "nan".to_string();
200    }
201    if f.is_infinite() {
202        return if f.is_sign_positive() {
203            "inf".to_string()
204        } else {
205            "-inf".to_string()
206        };
207    }
208    let a = f.abs();
209    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
210        // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
211        let s = format!("{:e}", f);
212        if let Some((m, e)) = s.split_once('e') {
213            let (sign, digits) = match e.strip_prefix('-') {
214                Some(d) => ("-", d.to_string()),
215                None => ("+", e.to_string()),
216            };
217            let digits = if digits.len() < 2 {
218                format!("0{}", digits)
219            } else {
220                digits
221            };
222            return format!("{}e{}{}", m, sign, digits);
223        }
224        s
225    } else if f.fract() == 0.0 {
226        // repr keeps the trailing ".0" that Rust's `{}` would drop.
227        format!("{:.1}", f)
228    } else {
229        format!("{}", f)
230    }
231}
232
233/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
234fn py_num_str(v: &FloatValue) -> String {
235    match v {
236        FloatValue::Float { value, .. } => py_float_str(*value),
237        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
238    }
239}
240
241/// CPython `float_divmod`'s floor-division half (`x // y` for floats).
242///
243/// NOT `(x / y).floor()`: CPython computes fmod first and derives the
244/// quotient from it, with a half-ulp correction. `to_year`'s guard hits this
245/// with negative floats (`-21.0 // 100 == -1.0`), which is exactly where
246/// floor and truncation part ways.
247fn py_f64_floordiv(x: f64, y: f64) -> f64 {
248    let m = x % y; // Rust f64 `%` is fmod, same as C's.
249    let mut div = (x - m) / y;
250    if m != 0.0 && ((y < 0.0) != (m < 0.0)) {
251        // CPython also folds the fmod into the divisor's sign here; only
252        // the quotient correction matters for `//`.
253        div -= 1.0;
254    }
255    if div != 0.0 {
256        let floordiv = div.floor();
257        if div - floordiv > 0.5 {
258            floordiv + 1.0
259        } else {
260            floordiv
261        }
262    } else {
263        0.0f64.copysign(x / y)
264    }
265}
266
267/// CPython `float_divmod`'s modulo half (`x % y` for floats): the result
268/// takes the *divisor*'s sign, unlike fmod.
269fn py_f64_mod(x: f64, y: f64) -> f64 {
270    let mut m = x % y;
271    if m != 0.0 {
272        if (y < 0.0) != (m < 0.0) {
273            m += y;
274        }
275    } else {
276        m = 0.0f64.copysign(y);
277    }
278    m
279}
280
281/// The suffix table shared by `to_ordinal_num` and its float entry:
282///
283/// ```python
284/// vaerdte = (0, 1, 5, 6, 11, 12)
285/// if value % 100 >= 30 and value % 100 <= 39 or value % 100 in vaerdte: "te"
286/// elif value % 100 == 2: "en"
287/// else: "ende"
288/// ```
289///
290/// `m` is `value % 100`, already reduced by the caller.
291fn ordinal_num_suffix(m: &BigInt) -> &'static str {
292    let vaerdte = [0i64, 1, 5, 6, 11, 12];
293    if (*m >= BigInt::from(30) && *m <= BigInt::from(39))
294        || vaerdte.iter().any(|v| *m == BigInt::from(*v))
295    {
296        "te"
297    } else if *m == BigInt::from(2) {
298        "en"
299    } else {
300        "ende"
301    }
302}
303
304/// `Num2Word_DA.merge`, with `self.ordflag` passed explicitly.
305///
306/// See the module docs for the `ntext` / `next` aliasing bug this preserves.
307fn da_merge(l: (&str, &BigInt), r: (&str, &BigInt), ordflag: bool) -> (String, BigInt) {
308    let (ctext_in, cnum) = l;
309    let (ntext_in, nnum) = r;
310
311    // Python unpacks `ctext, cnum, ntext, nnum = curr + next` up front. The
312    // block below rebinds `next` but NOT `ntext`, so the two diverge.
313    let mut ctext = ctext_in.to_string();
314    let mut ntext = ntext_in.to_string();
315    let mut next_text = ntext_in.to_string();
316    let next_num = nnum.clone();
317
318    let ten = BigInt::from(10);
319    let hundred = BigInt::from(100);
320    let thousand = BigInt::from(1000);
321    let hundred_thousand = BigInt::from(100_000);
322    let million = BigInt::from(1_000_000);
323
324    if nnum == &hundred || nnum == &thousand {
325        // These two return before the "et" prefix is applied.
326        if cnum == &ten && nnum == &thousand {
327            return ("ti tusind".to_string(), BigInt::from(10_000));
328        } else if cnum == &hundred && nnum == &thousand {
329            return ("ethundrede tusind".to_string(), BigInt::from(100_000));
330        }
331        next_text = format!("et{}", ntext_in);
332    }
333
334    if cnum.is_one() {
335        if nnum < &million || ordflag {
336            return (next_text, next_num);
337        }
338        ctext = "en".to_string();
339    }
340
341    let val: BigInt;
342    if nnum > cnum {
343        if nnum >= &million {
344            ctext.push(' ');
345        }
346        val = cnum * nnum;
347    } else {
348        if cnum >= &hundred && cnum < &thousand {
349            ctext.push_str(" og ");
350        } else if cnum >= &thousand && cnum <= &hundred_thousand {
351            ctext.push_str("e og ");
352        }
353        // Python chained comparison: nnum < 10 and 10 < cnum and cnum < 100.
354        if nnum < &ten && ten < *cnum && cnum < &hundred {
355            if nnum.is_one() {
356                ntext = "en".to_string();
357            }
358            // Python: `ntext, ctext = ctext, ntext + "og"` — RHS evaluated
359            // first, so ntext takes the OLD ctext.
360            let old_ctext = ctext;
361            ctext = format!("{}og", ntext);
362            ntext = old_ctext;
363        } else if cnum >= &million {
364            ctext.push(' ');
365        }
366        val = cnum + nnum;
367    }
368    (format!("{}{}", ctext, ntext), val)
369}
370
371/// `Num2Word_DA.CURRENCY_FORMS`, transcribed from the class body.
372///
373/// Safe to read literally: DA shadows `Num2Word_EUR.CURRENCY_FORMS` with its
374/// own dict, so English's import-time mutation never reaches it (see module
375/// docs). Every entry is a 2-tuple, which makes `pluralize`'s IndexError and
376/// the int arm's `len(cr1) > 1` fallback unreachable.
377///
378/// DA overrides EUR's SEK: `("krona", "kronor")/("öre", "öre")` becomes
379/// `("krone", "kroner")/("øre", "øre")` — Danish words for the Swedish
380/// currency. Kept as DA has it.
381fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
382    let mut m = HashMap::new();
383    m.insert(
384        "DKK",
385        CurrencyForms::new(&["krone", "kroner"], &["øre", "øre"]),
386    );
387    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "cent"]));
388    m.insert(
389        "USD",
390        CurrencyForms::new(&["dollar", "dollars"], &["cent", "cent"]),
391    );
392    m.insert(
393        "GBP",
394        CurrencyForms::new(&["pund", "pund"], &["penny", "pence"]),
395    );
396    m.insert(
397        "SEK",
398        CurrencyForms::new(&["krone", "kroner"], &["øre", "øre"]),
399    );
400    m.insert(
401        "NOK",
402        CurrencyForms::new(&["krone", "kroner"], &["øre", "øre"]),
403    );
404    m
405}
406
407/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged (DA does not
408/// redefine it, and EN only ever mutates `CURRENCY_FORMS`).
409///
410/// 14 of these 16 codes are dead weight: the adjective is only consulted
411/// *after* the `CURRENCY_FORMS` lookup succeeds, and DA's table has just six
412/// codes. Only USD ("US") and NOK ("Norwegian") are reachable — and only on
413/// the float arm, since DA's integer arm ignores `adjective=` (bug 10). The
414/// full table is kept because `currency_adjective` is a public hook that
415/// mirrors the Python attribute.
416fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
417    let mut m = HashMap::new();
418    m.insert("AUD", "Australian");
419    m.insert("BYN", "Belarusian");
420    m.insert("CAD", "Canadian");
421    m.insert("EEK", "Estonian");
422    m.insert("USD", "US");
423    m.insert("RUB", "Russian");
424    m.insert("NOK", "Norwegian");
425    m.insert("MXN", "Mexican");
426    m.insert("RON", "Romanian");
427    m.insert("INR", "Indian");
428    m.insert("HUF", "Hungarian");
429    m.insert("ISK", "íslenskar");
430    m.insert("UZS", "Uzbekistan");
431    m.insert("SAR", "Saudi");
432    m.insert("JPY", "Japanese");
433    m.insert("KRW", "Korean");
434    m
435}
436
437pub struct LangDa {
438    cards: Cards,
439    maxval: BigInt,
440    /// `self.ords` as an **ordered** list: `to_ordinal` scans it in Python
441    /// dict insertion order and breaks on the first suffix hit.
442    ords: Vec<(&'static str, &'static str)>,
443    exclude_title: Vec<String>,
444    currency_forms: HashMap<&'static str, CurrencyForms>,
445    currency_adjectives: HashMap<&'static str, &'static str>,
446}
447
448impl Default for LangDa {
449    fn default() -> Self {
450        Self::new()
451    }
452}
453
454impl LangDa {
455    pub fn new() -> Self {
456        // Num2Word_EUR.setup()
457        let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
458        let units = [
459            "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
460        ];
461        let tens = [
462            "dec",
463            "vigint",
464            "trigint",
465            "quadragint",
466            "quinquagint",
467            "sexagint",
468            "septuagint",
469            "octogint",
470            "nonagint",
471        ];
472        let mut high = vec!["cent".to_string()];
473        high.extend(gen_high_numwords(&units, &tens, &lows));
474
475        let mut cards = Cards::new();
476
477        // Num2Word_EUR.set_high_numwords, with DA's plural suffixes:
478        //   cap = 3 + 6*len(high) = 603
479        //   zip(high, range(cap, 3, -6))  → 100 pairs, n = 603 .. 9
480        let cap = 3 + 6 * high.len() as i64;
481        let mut n = cap;
482        for word in high.iter() {
483            if n <= 3 {
484                break; // mirrors range(cap, 3, -6) exhausting
485            }
486            cards.insert(
487                BigInt::from(10u8).pow(n as u32),
488                format!("{}illiarder", word), // GIGA_SUFFIX
489            );
490            cards.insert(
491                BigInt::from(10u8).pow((n - 3) as u32),
492                format!("{}illioner", word), // MEGA_SUFFIX
493            );
494            n -= 6;
495        }
496
497        set_mid_numwords(
498            &mut cards,
499            &[
500                (1000, "tusind"),
501                (100, "hundrede"),
502                (90, "halvfems"),
503                (80, "firs"),
504                (70, "halvfjerds"),
505                (60, "treds"),
506                (50, "halvtreds"),
507                (40, "fyrre"),
508                (30, "tredive"),
509            ],
510        );
511        set_low_numwords(
512            &mut cards,
513            &[
514                "tyve", "nitten", "atten", "sytten", "seksten", "femten", "fjorten", "tretten",
515                "tolv", "elleve", "ti", "ni", "otte", "syv", "seks", "fem", "fire", "tre", "to",
516                "et", "nul",
517            ],
518        );
519
520        // MAXVAL = 1000 * list(self.cards.keys())[0] = 1000 * 10**603
521        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
522
523        // Insertion order matters — see module docs, bug 5.
524        let ords: Vec<(&'static str, &'static str)> = vec![
525            ("nul", "nul"),
526            ("et", "første"), // Python source writes "f\xf8rste"
527            ("to", "anden"),
528            ("tre", "tredje"),
529            ("fire", "fjerde"),
530            ("fem", "femte"),
531            ("seks", "sjette"),
532            ("syv", "syvende"),
533            ("otte", "ottende"),
534            ("ni", "niende"),
535            ("ti", "tiende"),
536            ("elleve", "ellevte"),
537            ("tolv", "tolvte"),
538            ("tretten", "trett"),
539            ("fjorten", "fjort"),
540            ("femten", "femt"),
541            ("seksten", "sekst"),
542            ("sytten", "sytt"),
543            ("atten", "att"),
544            ("nitten", "nitt"),
545            ("tyve", "tyv"),
546        ];
547
548        LangDa {
549            cards,
550            maxval,
551            ords,
552            exclude_title: vec!["og".into(), "komma".into(), "minus".into()],
553            // Built once here, never per call: these are Python *class*
554            // attributes, so rebuilding them inside `to_currency` would be
555            // both wrong in spirit and needlessly slow.
556            currency_forms: build_currency_forms(),
557            currency_adjectives: build_currency_adjectives(),
558        }
559    }
560
561    /// `Num2Word_Base.verify_ordinal`. The float check is vacuous for BigInt;
562    /// the negative check raises TypeError (not ValueError).
563    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
564        if value.is_negative() {
565            return Err(N2WError::Type(format!(
566                "Cannot treat negative num {} as ordinal.",
567                value
568            )));
569        }
570        Ok(())
571    }
572
573    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
574    /// Python's order:
575    ///
576    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
577    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
578    ///
579    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
580    /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
581    /// "nulte", not an error. A negative fractional value (-1.5) fails
582    /// check 1 first, so it raises the *float* message, as Python does.
583    /// Returns the integral value for the integer-path continuation.
584    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
585        match value.as_whole_int() {
586            Some(i) => {
587                if i.is_negative() {
588                    Err(N2WError::Type(format!(
589                        "Cannot treat negative num {} as ordinal.",
590                        py_num_str(value)
591                    )))
592                } else {
593                    Ok(i)
594                }
595            }
596            None => Err(N2WError::Type(format!(
597                "Cannot treat float {} as ordinal.",
598                py_num_str(value)
599            ))),
600        }
601    }
602
603    /// `Num2Word_Base.to_splitnum` specialised to DA's only call site:
604    /// `to_splitnum(val, hightxt="hundrede", longval=True)` — so
605    /// `lowtxt`/`jointxt` are empty, `divisor` is 100 and `cents` is True.
606    fn to_splitnum(&self, val: &BigInt) -> Result<String> {
607        // Python's divmod floors; -44 → (-1, 56). See module docs, bug 8.
608        let (high, low) = val.div_mod_floor(&BigInt::from(100));
609        let mut out: Vec<String> = Vec::new();
610
611        // inflect(high, "hundrede"): "hundrede".split("/") has no separator,
612        // so both the ==1 and the join branch yield "hundrede" for every
613        // value. self.title() is identity while is_title is False.
614        let hightxt = "hundrede";
615
616        if !high.is_zero() {
617            out.push(self.to_cardinal(&high)?);
618            if !low.is_zero() {
619                // longval is always True here; jointxt is "" so it is skipped.
620                out.push(hightxt.to_string());
621            } else {
622                out.push(hightxt.to_string());
623            }
624        }
625        if !low.is_zero() {
626            out.push(self.to_cardinal(&low)?);
627            // lowtxt is "" → the inflect append is skipped.
628        }
629        Ok(out.join(" "))
630    }
631}
632
633/// Stateless stand-in for `self.ordflag == True`.
634///
635/// Runs the identical engine and card table, but `merge` takes the ordinal
636/// branch. `to_ordinal` uses this instead of mutating a field.
637struct DaOrd<'a> {
638    base: &'a LangDa,
639}
640
641impl Lang for DaOrd<'_> {
642    fn cards(&self) -> &Cards {
643        &self.base.cards
644    }
645    fn maxval(&self) -> &BigInt {
646        &self.base.maxval
647    }
648    fn negword(&self) -> &str {
649        "minus "
650    }
651    fn pointword(&self) -> &str {
652        "komma"
653    }
654    fn exclude_title(&self) -> &[String] {
655        &self.base.exclude_title
656    }
657    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
658        da_merge(l, r, true)
659    }
660}
661
662impl Lang for LangDa {
663    /// This language's own `to_currency(currency=...)` default,
664    /// read from the live Python signature. Only 44 of 156 use EUR.
665    fn default_currency(&self) -> &str {
666        "DKK"
667    }
668
669    /// This language's own `to_currency(separator=...)` default,
670    /// read from the live Python signature. Base's is ",", but only
671    /// 36 of 149 languages actually use it — most default to " " or a
672    /// conjunction, so inheriting Base's comma silently corrupts them.
673    fn default_separator(&self) -> &str {
674        ","
675    }
676
677    fn cards(&self) -> &Cards {
678        &self.cards
679    }
680    fn maxval(&self) -> &BigInt {
681        &self.maxval
682    }
683    fn negword(&self) -> &str {
684        "minus "
685    }
686    fn pointword(&self) -> &str {
687        "komma"
688    }
689    fn exclude_title(&self) -> &[String] {
690        &self.exclude_title
691    }
692
693    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
694        da_merge(l, r, false)
695    }
696
697    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
698        self.verify_ordinal(value)?;
699
700        // self.ordflag = True; outword = self.to_cardinal(value); ordflag = False
701        let engine = DaOrd { base: self };
702        let mut outword = default_to_cardinal(&engine, value)?;
703
704        // First insertion-ordered suffix hit wins, then break.
705        for (key, rep) in self.ords.iter() {
706            if outword.ends_with(key) {
707                // ends_with guarantees a char boundary; keys are ASCII anyway.
708                let cut = outword.len() - key.len();
709                outword = format!("{}{}", &outword[..cut], rep);
710                break;
711            }
712        }
713
714        // value is non-negative here, so % == mod_floor.
715        let m = value.mod_floor(&BigInt::from(100));
716        if (m >= BigInt::from(30) && m <= BigInt::from(39)) || m.is_zero() {
717            outword.push_str("te");
718        } else if m > BigInt::from(12) || m.is_zero() {
719            // `|| m.is_zero()` is dead — the arm above already took it.
720            outword.push_str("ende");
721        }
722        Ok(outword)
723    }
724
725    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
726        self.verify_ordinal(value)?;
727        let m = value.mod_floor(&BigInt::from(100));
728        Ok(format!("{}{}", value, ordinal_num_suffix(&m)))
729    }
730
731    // ---- float/Decimal entries -------------------------------------------
732    //
733    // Python's dispatcher hands floats/Decimals straight to the converter
734    // methods, so `verify_ordinal`'s float checks and `to_year`'s float
735    // branches become reachable here. `to_cardinal` needs no override: DA
736    // inherits base's `assert int(value) == value` routing, which is exactly
737    // the trait default (whole -> int path, fractional -> float grammar).
738
739    /// `to_ordinal(float/Decimal)`: verify_ordinal, then the integer path.
740    /// Whole values ordinalise (5.0 -> "femte", Decimal("1E+2") ->
741    /// "ethundredete", -0.0 -> "nulte"); fractional or negative values raise
742    /// TypeError (see module docs, bug 11). `value % 100` on a non-negative
743    /// whole float/Decimal equals the integer mod, so delegating to the
744    /// BigInt `to_ordinal` (ordflag engine + ords scan + suffix) is exact.
745    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
746        let i = self.verify_ordinal_float(value)?;
747        self.to_ordinal(&i)
748    }
749
750    /// `to_ordinal_num(float/Decimal)`: `str(value)` + the `% 100` suffix.
751    /// The numeric half is Python's `str(value)` (`repr_str`), so
752    /// Decimal("5.00") yields "5.00te", 2.0 yields "2.0en", 1e+16 yields
753    /// "1e+16te" and -0.0 yields "-0.0te".
754    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
755        let i = self.verify_ordinal_float(value)?;
756        let m = i.mod_floor(&BigInt::from(100));
757        Ok(format!("{}{}", repr_str, ordinal_num_suffix(&m)))
758    }
759
760    /// `Num2Word_DA.to_year(float/Decimal)` — see module docs, bugs 12/13.
761    ///
762    /// ```python
763    /// if val == 1: return "en"
764    /// if not (val // 100) % 10: return self.to_cardinal(val)
765    /// return self.to_splitnum(val, hightxt="hundrede", longval=longval)
766    /// ```
767    ///
768    /// The guard's `//` floors for floats but truncates for Decimals, and
769    /// `to_splitnum`'s float branch is `float2tuple` (int part / fractional
770    /// digits) while its Decimal branch is `divmod(val, 100)` — four
771    /// distinct behaviours from three lines of Python, all load-bearing:
772    /// 100.0 -> "ethundrede hundrede", Decimal('100') -> "et hundrede",
773    /// -1.5 -> "minus et hundrede fem", Decimal('-3.0') -> "minus tre".
774    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
775        match value {
776            FloatValue::Float { value: f, .. } => {
777                if *f == 1.0 {
778                    return Ok("en".to_string());
779                }
780                // `(val // 100) % 10` with CPython float_divmod semantics.
781                // NaN/inf never reach this entry (the dispatcher keeps them
782                // on the Python side), so the guard is finite arithmetic.
783                let guard = py_f64_mod(py_f64_floordiv(*f, 100.0), 10.0);
784                if guard == 0.0 {
785                    // Falsy guard (0.0 or -0.0): plain to_cardinal(val).
786                    return self.cardinal_float_entry(value, None);
787                }
788                // to_splitnum float branch: high/low from float2tuple, NOT
789                // divmod by 100 (bug 13). hightxt "hundrede" is appended
790                // whenever high is nonzero (inflect has no "/" to split on);
791                // lowtxt/jointxt are empty and cents=True.
792                let (high, low) = float2tuple(value);
793                let mut out: Vec<String> = Vec::new();
794                if !high.is_zero() {
795                    out.push(self.to_cardinal(&high)?);
796                    out.push("hundrede".to_string());
797                }
798                if !low.is_zero() {
799                    out.push(self.to_cardinal(&low)?);
800                }
801                Ok(out.join(" "))
802            }
803            FloatValue::Decimal { value: d, .. } => {
804                if *d == BigDecimal::from(1) {
805                    return Ok("en".to_string());
806                }
807                // Decimal `//` truncates toward zero and `%` keeps the
808                // dividend's sign, so the guard reduces to: trunc(d / 100)
809                // not divisible by 10. Truncation commutes with the integer
810                // divisor, so trunc(d) / 100 (BigInt `/`, truncating) is
811                // exact.
812                let pre = d.with_scale(0).as_bigint_and_exponent().0;
813                let q: BigInt = &pre / BigInt::from(100);
814                if (&q % BigInt::from(10)).is_zero() {
815                    return self.cardinal_float_entry(value, None);
816                }
817                // to_splitnum Decimal branch: `high, low = val` raises
818                // TypeError (Decimal is not iterable), caught -> divmod(val,
819                // 100): truncated quotient, remainder with the dividend's
820                // sign and the dividend's scale (45.000 keeps exponent -3).
821                let low = d.clone() - BigDecimal::from(&q * BigInt::from(100));
822                let mut out: Vec<String> = Vec::new();
823                if !q.is_zero() {
824                    out.push(self.to_cardinal(&q)?);
825                    out.push("hundrede".to_string());
826                }
827                if !low.is_zero() {
828                    // self.to_cardinal(Decimal): whole -> int path
829                    // ("femogfyrre"), fractional -> float grammar with the
830                    // remainder's own scale as precision.
831                    let (_, exp) = low.as_bigint_and_exponent();
832                    let fv = FloatValue::Decimal {
833                        value: low.clone(),
834                        precision: exp.unsigned_abs() as u32,
835                    };
836                    out.push(self.cardinal_float_entry(&fv, None)?);
837                }
838                Ok(out.join(" "))
839            }
840        }
841    }
842
843    fn to_year(&self, value: &BigInt) -> Result<String> {
844        if value.is_one() {
845            return Ok("en".to_string());
846        }
847        // Python: `if not (val // 100) % 10` — floored div, floored mod.
848        if value
849            .div_floor(&BigInt::from(100))
850            .mod_floor(&BigInt::from(10))
851            .is_zero()
852        {
853            return self.to_cardinal(value);
854        }
855        self.to_splitnum(value)
856    }
857
858    // ---- currency -------------------------------------------------------
859    //
860    // DA overrides only `to_currency` (its integer arm). `to_cheque`,
861    // `_money_verbose`, `_cents_verbose` and `_cents_terse` come from
862    // `Num2Word_Base` unchanged, and `pluralize` from `Num2Word_EUR`, so the
863    // trait defaults already cover them. `CURRENCY_PRECISION` is Base's empty
864    // dict — every code is divisor 100 — so `currency_precision` is left at
865    // its default too.
866
867    fn lang_name(&self) -> &str {
868        "Num2Word_DA"
869    }
870
871    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
872        self.currency_forms.get(code)
873    }
874
875    fn currency_adjective(&self, code: &str) -> Option<&str> {
876        self.currency_adjectives.get(code).copied()
877    }
878
879    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
880    ///
881    /// Python indexes the tuple directly, so a one-form entry with `n != 1`
882    /// would raise IndexError. Every DA entry is a 2-tuple, making that
883    /// unreachable — mapped to `Index` rather than a panic so the exception
884    /// type survives if the table ever changes.
885    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
886        let form = if n.is_one() { 0 } else { 1 };
887        forms
888            .get(form)
889            .cloned()
890            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
891    }
892
893    /// `Num2Word_DA.to_currency`.
894    ///
895    /// Python special-cases `isinstance(val, int)` to print the unit with no
896    /// cents segment, and hands everything else — including whole floats like
897    /// `1.0` — to `super()`. That int/float split is exactly the
898    /// [`CurrencyValue`] split, so it maps across directly.
899    ///
900    /// The `longval=True` parameter in the Python signature is accepted and
901    /// then never read; there is nothing to port.
902    fn to_currency(
903        &self,
904        val: &CurrencyValue,
905        currency: &str,
906        cents: bool,
907        separator: Option<&str>,
908        adjective: bool,
909    ) -> Result<String> {
910        // `None` == caller omitted `separator=`, so DA's own default (",")
911        // applies. The trait resolves this for the *default* body; an override
912        // has to do it itself.
913        let separator = separator.unwrap_or(self.default_separator());
914
915        if let CurrencyValue::Int(v) = val {
916            // Python wraps the lookup in `except (KeyError, AttributeError)`
917            // and hands the call to `super()` on a miss — which repeats the
918            // lookup and turns the KeyError into NotImplementedError.
919            // Delegating rather than raising here keeps that message coming
920            // from a single place. (The AttributeError half is unreachable:
921            // `CURRENCY_FORMS` always exists via the MRO.)
922            let forms = match self.currency_forms.get(currency) {
923                Some(f) => f,
924                None => {
925                    return default_to_currency(self, val, currency, cents, separator, adjective)
926                }
927            };
928
929            // `minus_str = self.negword if val < 0 else ""` — the raw negword,
930            // trailing space and all. Combined with the `"%s %s %s"` template
931            // below that yields "minus  tolv kroner". See module docs, bug 9.
932            let minus_str = if v.is_negative() { self.negword() } else { "" };
933            let abs_val = v.abs();
934            // Python calls `self.to_cardinal` here, not `self._money_verbose`
935            // — identical for DA, which does not override `_money_verbose`.
936            let money_str = self.to_cardinal(&abs_val)?;
937
938            // `cr1[0]` when abs_val == 1, else `cr1[1] if len(cr1) > 1 else
939            // cr1[0]`. Hand-inlined in Python rather than routed through
940            // `pluralize`, though it agrees with EUR's rule for 2-tuples.
941            let cr1 = &forms.unit;
942            let currency_str = if abs_val.is_one() {
943                cr1.first()
944            } else {
945                cr1.get(1).or_else(|| cr1.first())
946            }
947            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
948
949            // NOTE: `adjective` is deliberately not consulted on this arm —
950            // Python's integer branch never reads CURRENCY_ADJECTIVES. See
951            // module docs, bug 10. (It *is* honoured on the float arm below.)
952            //
953            // Python: ("%s %s %s" % (...)).strip(). `trim()` matches `strip()`
954            // on the ends only; it must NOT collapse the interior double space
955            // that a negative `minus_str` introduces.
956            return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
957                .trim()
958                .to_string());
959        }
960
961        // Floats/Decimals: `super(Num2Word_DA, self).to_currency(...)`, which
962        // is `Num2Word_Base.to_currency` (EUR defines none). This is the arm
963        // that honours `adjective=` and renders the cents segment.
964        default_to_currency(self, val, currency, cents, separator, adjective)
965    }
966}