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}