Skip to main content

num2words2_core/
lang_fr_dz.rs

1//! Port of `lang_FR_DZ.py` (French — Algeria), registry key `"fr_DZ"`.
2//!
3//! Shape: **engine**. The full chain is
4//! `Num2Word_FR_DZ` → `Num2Word_FR` → `Num2Word_EUR` → `Num2Word_Base`.
5//!
6//! `Num2Word_FR_DZ` overrides **only** `CURRENCY_FORMS` and `to_currency`. The
7//! integer modes (`to_cardinal`/`to_ordinal`/`to_ordinal_num`/`to_year`)
8//! therefore behave *identically to plain `fr`* — that half of this file is a
9//! port of `Num2Word_FR` with the FR_DZ name. If `lang_fr.rs` and this file
10//! ever disagree on those four modes, one of them is wrong.
11//!
12//! # The `super(Num2Word_FR, self)` MRO skip — the trap in this file
13//!
14//! `Num2Word_FR_DZ.to_currency` looks like a pass-through wrapper, but the
15//! `super()` call names an explicit *start* class:
16//!
17//! ```python
18//! def to_currency(self, val, currency="DZD", cents=True,
19//!                 separator=" et", adjective=False):
20//!     return super(Num2Word_FR, self).to_currency(...)   # NOT super()
21//! ```
22//!
23//! `super(Num2Word_FR, self)` resolves to the entry *after* `Num2Word_FR` in
24//! `type(self).__mro__`, i.e. `Num2Word_EUR` — which defines no `to_currency`
25//! — so it lands on `Num2Word_Base.to_currency`. **`Num2Word_FR.to_currency` is
26//! skipped entirely**, even though FR is this class's direct parent. Verified
27//! against the live interpreter:
28//! `super(Num2Word_FR, fr_dz).to_currency.__func__.__qualname__` is
29//! `"Num2Word_Base.to_currency"`.
30//!
31//! That matters because FR's `to_currency` is not a superset of Base's — it
32//! *differs observably*:
33//!
34//! * FR special-cases `isinstance(val, int)` and, on a **missing currency
35//!   code, returns `self.to_cardinal(val)` instead of raising** — a silent
36//!   fallback. Base raises `NotImplementedError`.
37//! * FR's int branch ignores `adjective=` outright.
38//!
39//! The corpus pins the difference, and it is the single discriminator between
40//! a correct port and a plausible-looking wrong one:
41//!
42//! ```text
43//! fr    to_currency(1, "GBP") -> "un livre"            # FR: found in FR's table
44//! fr    to_currency(1, "DZD") -> "un"                  # FR: KeyError -> to_cardinal
45//! fr_DZ to_currency(1, "GBP") -> NotImplementedError   # Base: raises
46//! fr_DZ to_currency(1, "DZD") -> "un dinar"
47//! ```
48//!
49//! So `to_currency` is **not** overridden below: the trait default already
50//! *is* `Num2Word_Base.to_currency` (`currency::default_to_currency`), which is
51//! exactly what the MRO skip selects. The `currency="DZD"` / `separator=" et"`
52//! kwarg defaults are carried by the generated `default_currency` /
53//! `default_separator` hooks. Routing this through a hand-written copy of
54//! `Num2Word_FR.to_currency` would turn all 24 `NotImplementedError` int rows
55//! (GBP/JPY/KWD/BHD/INR/CNY/CHF × `0`,`1`,`2`,`100`,`1000000`) into bare
56//! cardinals.
57//!
58//! # Currency data, as the live class actually sees it
59//!
60//! * `CURRENCY_FORMS` is FR_DZ's **own** class attribute — three codes only
61//!   (DZD/EUR/USD). It shadows both `Num2Word_FR`'s 14-code table and
62//!   `Num2Word_EUR`'s 23-code one, so every other code raises. Being its own
63//!   dict, it is also immune to the `Num2Word_EN.__init__` in-place mutation
64//!   that rewrites `Num2Word_EUR.CURRENCY_FORMS` at import time (see
65//!   `PORTING_CURRENCY.md`) — EUR here is FR_DZ's `("euro", "euros")`, never
66//!   EUR's `("euro", "euro")` nor EN's rewrite of it.
67//! * `CURRENCY_ADJECTIVES` is **not** redefined, so the 16-entry
68//!   `Num2Word_EUR` table is inherited (EN leaves it alone).
69//! * `CURRENCY_PRECISION` is `{}` all the way up to `Num2Word_Base`, so
70//!   `.get(code, 100)` is always 100 — the `currency_precision` hook keeps its
71//!   default. There is no 3-decimal (KWD/BHD → 1000) or 0-decimal (JPY → 1)
72//!   currency in this language: those codes are absent from the table and
73//!   raise before precision is ever consulted.
74//! * `pluralize` comes from `Num2Word_FR`, not `Num2Word_EUR`: `abs(n) <= 1`
75//!   picks the singular, so **zero takes the singular** ("zéro euro et un
76//!   centime"). EUR's `n == 1` rule would emit "zéro euros" and fail the
77//!   corpus.
78//! * `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse` are
79//!   all `Num2Word_Base`'s — the trait defaults already mirror them.
80//!
81//! Card table (built in `new()`, mirroring the Python `__init__` → `setup` →
82//! `set_numwords` sequence):
83//!   * `Num2Word_EUR.setup` builds 100 `high_numwords`
84//!     (`["cent"] + gen_high_numwords(units, tens, lows)`).
85//!   * `Num2Word_EUR.set_high_numwords` is **long scale**: `cap = 3 + 6*100 =
86//!     603`, and each word `w` at exponent `n` contributes *two* cards —
87//!     `10^n → w+"illiard"` and `10^(n-3) → w+"illion"`. So `10^6` is
88//!     "million", `10^9` "milliard", `10^12` "billion", `10^15` "billiard",
89//!     `10^18` "trillion", `10^21` "trilliard". (Contrast `lang_en.rs`, which
90//!     overrides this to the short scale with step -3.)
91//!   * Highest card is `10^603` ("centilliard"), so
92//!     `MAXVAL = 1000 * 10^603 = 10^606`. Values are `BigInt` throughout —
93//!     nothing here is bounded by `u64`/`i128`.
94//!   * `Num2Word_FR.setup` supplies mid cards with **no 70 and no 90**: French
95//!     builds those additively (60+10 → "soixante-dix", 80+10 →
96//!     "quatre-vingt-dix"), which is what drives `merge`'s hyphen rules.
97//!
98//! # Faithfully reproduced Python bugs / oddities
99//!
100//! This is a port, not a rewrite. All of the following are exactly what Python
101//! emits and are pinned by rows in `bench/corpus.jsonl`:
102//!
103//! 1. `to_ordinal(0)` == **"zéroième"**. The `for...else` fallback only strips
104//!    a trailing "e" before appending "ième"; "zéro" ends in "o", so the vowel
105//!    survives. Real French is "zéroième"-less (there is no such word), but the
106//!    corpus pins it.
107//! 2. `to_ordinal_num(0)` == **"0me"** and `to_ordinal_num(2)` == "2me". The
108//!    Python is a bare `"er" if value == 1 else "me"`, so it emits neither the
109//!    correct French "0e"/"2e" nor "2ème" — and no feminine/plural variants.
110//! 3. `to_ordinal(1000001)` == **"un million unième"** while
111//!    `to_ordinal(1000000)` == "millionième". The big-unit branch only fires on
112//!    an *exact* `"un " + unit` match or a `" <unit>s"` suffix, so any trailing
113//!    remainder falls through to the generic suffixing. The Python source even
114//!    carries a comment questioning this ("Is this right for such things as
115//!    1001 ... ?"); it is preserved as-is.
116//! 4. `word.endswith("ts") or word.endswith("ents")` in the fallback — the
117//!    `"ents"` arm is dead code, fully subsumed by `"ts"`. Kept verbatim.
118//! 5. `stripped = word.rstrip("s")` strips *every* trailing "s", not just one.
119//!    Harmless for the current card words but reproduced exactly.
120//! 6. `to_currency(1000000, "EUR")` == **"un million euros"**, not the correct
121//!    French "un million d'euros". `Num2Word_Base.to_currency` just joins the
122//!    cardinal to `pluralize(...)`, and nothing in the chain knows about the
123//!    elided "de". Pinned by a corpus row.
124//! 7. `to_cheque` emits the **English** "AND" between the words and the
125//!    fraction — "MILLE DEUX CENT TRENTE-QUATRE AND 56/100 EUROS". The literal
126//!    is hardcoded in `Num2Word_Base.to_cheque` and FR_DZ inherits it, so a
127//!    French cheque comes out half-English. Pinned by a corpus row.
128//! 8. `to_cheque`'s subunit is `int((abs_val - whole) * divisor)` — a
129//!    *truncation*, unlike `to_currency`'s ROUND_HALF_UP. So `999.999 EUR`
130//!    cheques as "…QUATRE-VINGT-DIX-NEUF AND 99/100 EUROS" (99 cents, not a
131//!    carry to 1000) and `2.675 USD` as "67/100".
132//!
133//! # Deliberate raises
134//!
135//! `verify_ordinal` (inherited unchanged from `Num2Word_Base`) raises
136//! `TypeError` for negatives, so `to_ordinal(-1)` and `to_ordinal_num(-1)` are
137//! `N2WError::Type` — matching the corpus. `to_cardinal` is unaffected and
138//! renders "moins un".
139//!
140//! An unknown currency code raises `NotImplementedError` from both
141//! `to_currency` and `to_cheque` (`N2WError::NotImplemented`), with Python's
142//! exact text — `Currency code "GBP" not implemented for "Num2Word_FR_DZ"`.
143//! That is 84 of the 117 currency/cheque corpus rows, because the table has
144//! only three codes. `pluralize`'s tuple index is the one latent
145//! `IndexError`, unreachable with the current two-form entries. No
146//! `KeyError`/`ValueError` crash sites exist in this chain.
147//!
148//! # Cross-call mutable state
149//!
150//! None. `Num2Word_FR`/`_FR_DZ` define no `_pending_*` handshake (contrast
151//! `lang_ES.str_to_number`). Python writes `self.precision` on the
152//! float/Decimal cardinal path, which fractional-cent inputs (`1.011 USD` →
153//! "un dollar et un virgule un cents") reach via `cardinal_from_decimal`; the
154//! Rust equivalent in `floatpath` threads precision through as an argument
155//! instead, so nothing here is stateful. Safe to call statelessly.
156
157use crate::base::{
158    clean, set_low_numwords, set_mid_numwords, splitnum, Cards, Lang, N2WError, Node, Result,
159};
160use crate::currency::CurrencyForms;
161use crate::floatpath::FloatValue;
162use num_bigint::BigInt;
163use num_integer::Integer;
164use num_traits::{FromPrimitive, One, Signed, Zero};
165use std::collections::HashMap;
166
167/// Port of `Num2Word_FR._BIG_UNITS`. Order is load-bearing: "trilliard" is
168/// tested before "trillion", "billiard" before "billion", "milliard" before
169/// "million", so the longer long-scale name always wins.
170const BIG_UNITS: &[&str] = &[
171    "trilliard",
172    "trillion",
173    "billiard",
174    "billion",
175    "milliard",
176    "million",
177];
178
179/// Port of `Num2Word_EUR.gen_high_numwords`.
180///
181/// `[u + t for t in tens for u in units]` — `tens` is the **outer** loop — then
182/// reversed, then the Latin-prefix elisions are applied in order.
183fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
184    let mut out: Vec<String> = Vec::new();
185    for t in tens {
186        for u in units {
187            out.push(format!("{}{}", u, t));
188        }
189    }
190    out.reverse();
191
192    const REPLACEMENTS: &[(&str, &str)] = &[
193        ("novemn", "noven"),
194        ("novemo", "novo"),
195        ("octoo", "octo"),
196        ("quintd", "quind"),
197        ("quintn", "quin"),
198        ("quintq", "quinq"),
199        ("quints", "quins"),
200        ("quintt", "quint"),
201        ("quintv", "quinv"),
202        ("septenn", "septen"),
203        ("septent", "sept"),
204        ("sexn", "sen"),
205        ("sexs", "ses"),
206        ("tresd", "tred"),
207        ("tresn", "tren"),
208        ("tress", "tres"),
209        ("tresv", "trev"),
210        ("unno", "uno"),
211    ];
212    for (k, v) in REPLACEMENTS {
213        out = out.iter().map(|o| o.replace(k, v)).collect();
214    }
215    out.extend(lows.iter().map(|s| s.to_string()));
216    out
217}
218
219/// `Num2Word_FR_DZ.CURRENCY_FORMS` — the class's own dict, verbatim.
220///
221/// Deliberately **not** merged with `Num2Word_FR`'s or `Num2Word_EUR`'s tables:
222/// a subclass attribute shadows the parents' rather than extending them, so
223/// these three codes are the *entire* table. Every other code — including the
224/// GBP/JPY/KWD/BHD/INR/CNY/CHF the corpus probes — raises
225/// `NotImplementedError`. Both sides carry two forms, which is all
226/// `Num2Word_FR.pluralize` ever indexes.
227fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
228    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
229    m.insert(
230        "DZD",
231        CurrencyForms::new(&["dinar", "dinars"], &["centime", "centimes"]),
232    );
233    m.insert(
234        "EUR",
235        CurrencyForms::new(&["euro", "euros"], &["centime", "centimes"]),
236    );
237    m.insert("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
238    m
239}
240
241/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged (FR, FR_DZ and EN
242/// all leave it alone).
243///
244/// Ported whole because it is the inherited data, but only `USD` is reachable:
245/// `to_currency` looks the code up in `CURRENCY_FORMS` first and raises for
246/// anything missing, and USD is the only entry present in both tables. So
247/// `adjective=True` can only ever produce "US dollar"/"US dollars".
248fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
249    [
250        ("AUD", "Australian"),
251        ("BYN", "Belarusian"),
252        ("CAD", "Canadian"),
253        ("EEK", "Estonian"),
254        ("USD", "US"),
255        ("RUB", "Russian"),
256        ("NOK", "Norwegian"),
257        ("MXN", "Mexican"),
258        ("RON", "Romanian"),
259        ("INR", "Indian"),
260        ("HUF", "Hungarian"),
261        ("ISK", "íslenskar"),
262        ("UZS", "Uzbekistan"),
263        ("SAR", "Saudi"),
264        ("JPY", "Japanese"),
265        ("KRW", "Korean"),
266    ]
267    .into_iter()
268    .collect()
269}
270
271/// Python `str(float)`/`repr(float)`, used only to build error-message text
272/// (`errmsg_floatord`, `errmsg_negord`). The corpora compare exception
273/// *types*; the text follows the Python format strings. Whole floats keep
274/// their ".0" ("21.0"), |v| >= 1e16 switches to Python's exponent form
275/// ("1e+20"), and -0.0 keeps its sign.
276fn py_float_str(f: f64) -> String {
277    if f.is_nan() {
278        return "nan".to_string();
279    }
280    if f.is_infinite() {
281        return if f.is_sign_positive() {
282            "inf".to_string()
283        } else {
284            "-inf".to_string()
285        };
286    }
287    let a = f.abs();
288    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
289        // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
290        let s = format!("{:e}", f);
291        if let Some((m, e)) = s.split_once('e') {
292            let (sign, digits) = match e.strip_prefix('-') {
293                Some(d) => ("-", d.to_string()),
294                None => ("+", e.to_string()),
295            };
296            let digits = if digits.len() < 2 {
297                format!("0{}", digits)
298            } else {
299                digits
300            };
301            return format!("{}e{}{}", m, sign, digits);
302        }
303        s
304    } else if f.fract() == 0.0 {
305        // repr keeps the trailing ".0" that Rust's `{}` would drop.
306        format!("{:.1}", f)
307    } else {
308        format!("{}", f)
309    }
310}
311
312/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
313fn py_num_str(v: &FloatValue) -> String {
314    match v {
315        FloatValue::Float { value, .. } => py_float_str(*value),
316        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
317    }
318}
319
320pub struct LangFrDz {
321    cards: Cards,
322    maxval: BigInt,
323    /// `Num2Word_FR.setup`'s `self.ords`. A `Vec` rather than a `HashMap`
324    /// because Python iterates `self.ords.items()` in insertion order and
325    /// `break`s on the first `endswith` hit.
326    ords: Vec<(&'static str, &'static str)>,
327    exclude_title: Vec<String>,
328    currency_forms: HashMap<&'static str, CurrencyForms>,
329    currency_adjectives: HashMap<&'static str, &'static str>,
330}
331
332impl Default for LangFrDz {
333    fn default() -> Self {
334        Self::new()
335    }
336}
337
338impl LangFrDz {
339    pub fn new() -> Self {
340        // --- Num2Word_EUR.setup ---
341        let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
342        let units = [
343            "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
344        ];
345        let tens = [
346            "dec",
347            "vigint",
348            "trigint",
349            "quadragint",
350            "quinquagint",
351            "sexagint",
352            "septuagint",
353            "octogint",
354            "nonagint",
355        ];
356        let mut high = vec!["cent".to_string()];
357        high.extend(gen_high_numwords(&units, &tens, &lows));
358        debug_assert_eq!(high.len(), 100);
359
360        let mut cards = Cards::new();
361
362        // --- Num2Word_EUR.set_high_numwords (long scale, step -6) ---
363        // cap = 3 + 6*len(high); zip(high, range(cap, 3, -6)).
364        // Both sequences have 100 entries, so the zip exhausts them together;
365        // the `n <= 3` break mirrors range()'s exclusive stop.
366        let cap: i64 = 3 + 6 * high.len() as i64;
367        let mut n = cap;
368        for word in high.iter() {
369            if n <= 3 {
370                break;
371            }
372            // GIGA_SUFFIX then MEGA_SUFFIX, matching the Python insertion order
373            // (descending, which is also `Cards`'s sort order).
374            cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}illiard", word));
375            cards.insert(
376                BigInt::from(10u8).pow((n - 3) as u32),
377                format!("{}illion", word),
378            );
379            n -= 6;
380        }
381
382        // --- Num2Word_FR.setup: mid/low numwords ---
383        // Note the absence of 70 and 90 — French composes them additively.
384        set_mid_numwords(
385            &mut cards,
386            &[
387                (1000, "mille"),
388                (100, "cent"),
389                (80, "quatre-vingts"),
390                (60, "soixante"),
391                (50, "cinquante"),
392                (40, "quarante"),
393                (30, "trente"),
394            ],
395        );
396        set_low_numwords(
397            &mut cards,
398            &[
399                "vingt", "dix-neuf", "dix-huit", "dix-sept", "seize", "quinze", "quatorze",
400                "treize", "douze", "onze", "dix", "neuf", "huit", "sept", "six", "cinq", "quatre",
401                "trois", "deux", "un", "zéro",
402            ],
403        );
404
405        // MAXVAL = 1000 * list(self.cards.keys())[0] = 1000 * 10^603 = 10^606.
406        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
407
408        LangFrDz {
409            cards,
410            maxval,
411            ords: vec![("cinq", "cinquième"), ("neuf", "neuvième")],
412            exclude_title: vec!["et".into(), "virgule".into(), "moins".into()],
413            // Built once here, never per call. `to_currency`/`to_cheque` only
414            // read these; rebuilding them per call is what made an earlier
415            // revision of this port slower than the Python it replaces.
416            currency_forms: build_currency_forms(),
417            currency_adjectives: build_currency_adjectives(),
418        }
419    }
420
421    /// Port of `Num2Word_Base.verify_ordinal`.
422    ///
423    /// The float check (`not value == int(value)`) cannot fire for integral
424    /// input, so only the negative check survives.
425    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
426        if value.is_negative() {
427            return Err(N2WError::Type(format!(
428                "Cannot treat negative num {} as ordinal.",
429                value
430            )));
431        }
432        Ok(())
433    }
434
435    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
436    /// Python's order:
437    ///
438    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
439    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
440    ///
441    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
442    /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
443    /// "zéroième", not an error. A negative fractional value (-1.5) fails
444    /// check 1 first, so it raises the *float* message, as Python does.
445    /// Returns the integral value for the integer-path continuation.
446    /// (NaN/±inf would make `int(value)` raise before either check, but the
447    /// dispatcher keeps those on the Python side; `as_whole_int` maps them to
448    /// the float-message arm, which is unreachable for them in practice.)
449    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
450        match value.as_whole_int() {
451            Some(i) => {
452                if i.is_negative() {
453                    Err(N2WError::Type(format!(
454                        "Cannot treat negative num {} as ordinal.",
455                        py_num_str(value)
456                    )))
457                } else {
458                    Ok(i)
459                }
460            }
461            None => Err(N2WError::Type(format!(
462                "Cannot treat float {} as ordinal.",
463                py_num_str(value)
464            ))),
465        }
466    }
467}
468
469impl Lang for LangFrDz {
470    /// This language's own `to_currency(currency=...)` default,
471    /// read from the live Python signature. Only 44 of 156 use EUR.
472    fn default_currency(&self) -> &str {
473        "DZD"
474    }
475
476    /// This language's own `to_currency(separator=...)` default,
477    /// read from the live Python signature. Base's is ",", but only
478    /// 36 of 149 languages actually use it — most default to " " or a
479    /// conjunction, so inheriting Base's comma silently corrupts them.
480    fn default_separator(&self) -> &str {
481        " et"
482    }
483
484    fn cards(&self) -> &Cards {
485        &self.cards
486    }
487    fn maxval(&self) -> &BigInt {
488        &self.maxval
489    }
490    fn negword(&self) -> &str {
491        "moins "
492    }
493    fn pointword(&self) -> &str {
494        "virgule"
495    }
496    fn exclude_title(&self) -> &[String] {
497        &self.exclude_title
498    }
499
500    /// `Num2Word_Base.to_cardinal`, re-implemented rather than delegated to
501    /// `base::default_to_cardinal` for one reason: `Num2Word_FR.setup`
502    /// overrides `errmsg_toobig` with a French message, and the overflow text
503    /// is observable. Everything else is identical to the base engine.
504    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
505        let mut out = String::new();
506        let mut v = value.clone();
507        if v.is_negative() {
508            v = v.abs();
509            // Python: "%s " % self.negword.strip() → "moins " (strip then re-add
510            // the single space), *not* the raw negword.
511            out = format!("{} ", self.negword().trim());
512        }
513
514        if &v >= self.maxval() {
515            return Err(N2WError::Overflow(format!(
516                "Nombre trop grand pour être converti en mots (abs({}) > {}).",
517                v,
518                self.maxval()
519            )));
520        }
521
522        let tree = splitnum(self, &v).ok_or_else(|| {
523            N2WError::Overflow(format!(
524                "Nombre trop grand pour être converti en mots (abs({}) > {}).",
525                v,
526                self.maxval()
527            ))
528        })?;
529        let words = match clean(self, tree) {
530            Node::Leaf(t, _) => t,
531            Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
532        };
533        Ok(self.title(&format!("{}{}", out, words)))
534    }
535
536    /// Port of `Num2Word_FR.merge`.
537    ///
538    /// Python unpacks `ctext, cnum, ntext, nnum = curr + next` (tuple concat).
539    ///
540    /// The `cnum == 1` arm has no `return` when `nnum >= 1000000`: it falls
541    /// through to the tail comparisons with `ctext`/`ntext` **unmodified**.
542    /// That is what makes 10^6 render as "un million" while 100 renders as
543    /// bare "cent". The structure below preserves that fall-through exactly.
544    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
545        let (ctext0, cnum) = l;
546        let (ntext0, nnum) = r;
547        let mut ctext = ctext0.to_string();
548        let mut ntext = ntext0.to_string();
549
550        let ten = BigInt::from(10);
551        let eighty = BigInt::from(80);
552        let hundred = BigInt::from(100);
553        let thousand = BigInt::from(1000);
554        let million = BigInt::from(1_000_000);
555
556        if cnum.is_one() {
557            if nnum < &million {
558                return (ntext, nnum.clone());
559            }
560            // else: fall through, no text fixups.
561        } else {
562            // Python: `not (cnum - 80) % 100 or (not cnum % 100 and cnum < 1000)`
563            // — `%` binds tighter than `not`. mod_floor reproduces Python's
564            // floor-mod; cnum is always >= 0 here, but (cnum - 80) can be
565            // negative, so the distinction is real.
566            let drop_s = (cnum - &eighty).mod_floor(&hundred).is_zero()
567                || (cnum.mod_floor(&hundred).is_zero() && cnum < &thousand);
568            // `ctext[-1]` is a *character* index in Python — French words carry
569            // non-ASCII ("zéro"), so never index by byte.
570            if drop_s && nnum < &million && ctext.chars().last() == Some('s') {
571                ctext.pop(); // ctext[:-1]
572            }
573            // Pluralize a round `next` ("deux cents", "neuf millions") — but
574            // never "mille" (nnum != 1000), and never double up an existing 's'
575            // ("cent vingt-trois" stays put).
576            if cnum < &thousand
577                && nnum != &thousand
578                && ntext.chars().last() != Some('s')
579                && nnum.mod_floor(&hundred).is_zero()
580            {
581                ntext.push('s');
582            }
583        }
584
585        // Python's chained `nnum < cnum < 100`.
586        if nnum < cnum && cnum < &hundred {
587            // "vingt-et-un", "soixante-et-onze" — but 80 is excluded, giving
588            // "quatre-vingt-un" and "quatre-vingt-onze".
589            if nnum.mod_floor(&ten).is_one() && cnum != &eighty {
590                return (format!("{}-et-{}", ctext, ntext), cnum + nnum);
591            }
592            return (format!("{}-{}", ctext, ntext), cnum + nnum);
593        }
594        if nnum > cnum {
595            return (format!("{} {}", ctext, ntext), cnum * nnum);
596        }
597        (format!("{} {}", ctext, ntext), cnum + nnum)
598    }
599
600    /// Port of `Num2Word_FR.to_ordinal`.
601    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
602        self.verify_ordinal(value)?;
603        if value.is_one() {
604            return Ok("premier".to_string());
605        }
606        let mut word = self.to_cardinal(value)?;
607
608        // Big-unit ordinals: "un million" → "millionième", "dix millions" →
609        // "dix millionièmes". Only exact-`un` or plural-suffix forms qualify;
610        // "un million un" deliberately does not (see module docs, bug 3).
611        for unit in BIG_UNITS {
612            if word == format!("un {}", unit) || word.ends_with(&format!(" {}s", unit)) {
613                let plural = word.ends_with('s');
614                // rstrip("s") removes *all* trailing 's', not just one.
615                let mut stripped = word.trim_end_matches('s').to_string();
616                if let Some(rest) = stripped.strip_prefix("un ") {
617                    stripped = rest.to_string();
618                }
619                return Ok(format!(
620                    "{}ième{}",
621                    stripped,
622                    if plural { "s" } else { "" }
623                ));
624            }
625            // Unreachable in practice: a bare unit word never escapes merge
626            // without its "un ". Ported for completeness.
627            if word == *unit {
628                return Ok(format!("{}ième", unit));
629            }
630        }
631
632        // Python `for src, repl in self.ords.items(): ... break` / `else:`.
633        // `matched` models the for/else: the else arm runs only if no break.
634        let mut matched = false;
635        for (src, repl) in &self.ords {
636            if word.ends_with(src) {
637                // `src` is pure ASCII and `ends_with` guarantees the boundary,
638                // so slicing off `src.len()` bytes == Python's `word[:-len(src)]`.
639                word = format!("{}{}", &word[..word.len() - src.len()], repl);
640                matched = true;
641                break;
642            }
643        }
644        if !matched {
645            if word.chars().last() == Some('e') {
646                word.pop(); // word[:-1]
647            }
648            // Drop the trailing 's' of "cents"/"vingts": 200 → "deux centième".
649            // The "ents" arm is dead code in Python; kept verbatim.
650            if word.ends_with("ts") || word.ends_with("ents") {
651                word.pop();
652            }
653            word.push_str("ième");
654        }
655        Ok(word)
656    }
657
658    /// Port of `Num2Word_FR.to_ordinal_num`: `str(value) + ("er" | "me")`.
659    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
660        self.verify_ordinal(value)?;
661        let mut out = value.to_string();
662        out.push_str(if value.is_one() { "er" } else { "me" });
663        Ok(out)
664    }
665
666    /// Port of `Num2Word_FR.to_year`: plain cardinal, no era suffix and no
667    /// century splitting (contrast EN's "nineteen oh-five").
668    fn to_year(&self, value: &BigInt) -> Result<String> {
669        self.to_cardinal(value)
670    }
671
672    // ---- float/Decimal entries -------------------------------------------
673    //
674    // Python's dispatcher hands floats/Decimals straight to the converter
675    // methods, so `verify_ordinal`'s float/negative checks and `to_year`'s
676    // `int()` truncation become reachable here — unlike on the BigInt hooks
677    // above, where they are dead code. `to_cardinal` needs no override: the
678    // FR_DZ → FR → EUR → Base chain inherits base's `assert int(value) ==
679    // value` routing, which is exactly the trait default (whole -> int path).
680
681    /// `Num2Word_FR.to_ordinal(float/Decimal)` (inherited by FR_DZ):
682    /// `verify_ordinal`, then the integer path. Whole values ordinalise
683    /// (5.0 -> "cinquième", 1.0 -> "premier" via the `value == 1` special
684    /// case, Decimal("1E+2") -> "centième", 1e+16 -> the plural quirk
685    /// "dix billiardièmes"); fractional or negative values raise TypeError.
686    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
687        let i = self.verify_ordinal_float(value)?;
688        self.to_ordinal(&i)
689    }
690
691    /// `Num2Word_FR.to_ordinal_num(float/Decimal)`:
692    /// `str(value) + ("er" if value == 1 else "me")`. The numeric half is
693    /// Python's `str(value)` (`repr_str`), so 1.0 yields "1.0er",
694    /// Decimal("5.00") "5.00me", -0.0 "-0.0me" and Decimal("1E+20")
695    /// "1E+20me". No overflow is possible — Python never words the value.
696    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
697        let i = self.verify_ordinal_float(value)?;
698        Ok(format!(
699            "{}{}",
700            repr_str,
701            if i.is_one() { "er" } else { "me" }
702        ))
703    }
704
705    /// `Num2Word_FR.to_year(float/Decimal)`: `self.to_cardinal(int(val))` —
706    /// `int()` truncates toward zero, so 2.5 -> "deux", -1.5 -> "moins un",
707    /// 0.5 -> "zéro". No non-integer guard (contrast EN's TypeError):
708    /// fractional values are silently truncated, floats and Decimals alike.
709    /// NaN/±inf floats reproduce `int()`'s ValueError/OverflowError, though
710    /// the dispatcher keeps those on the Python side.
711    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
712        let i = match value {
713            FloatValue::Float { value: f, .. } => {
714                if f.is_nan() {
715                    return Err(N2WError::Value(
716                        "cannot convert float NaN to integer".into(),
717                    ));
718                }
719                if f.is_infinite() {
720                    return Err(N2WError::Overflow(
721                        "cannot convert float infinity to integer".into(),
722                    ));
723                }
724                // Exact: every whole f64 is exactly representable.
725                BigInt::from_f64(f.trunc()).ok_or_else(|| {
726                    N2WError::Value("cannot convert float NaN to integer".into())
727                })?
728            }
729            // int(Decimal) truncates toward zero.
730            FloatValue::Decimal { value: d, .. } => d.with_scale(0).as_bigint_and_exponent().0,
731        };
732        self.to_cardinal(&i)
733    }
734
735    // ---- fractions --------------------------------------------------------
736
737    /// Port of `Num2Word_FR.to_fraction` (issue #584), inherited unchanged by
738    /// `Num2Word_FR_DZ`: idiomatic demi/tiers/quart for denominators 2/3/4 —
739    /// "tiers" is invariant in the plural ("1000000/3" -> "un million
740    /// tiers"), demi/quart take -s. Every other denominator is
741    /// ordinal-as-noun, appending "s" only when the numerator isn't 1 *and*
742    /// the ordinal doesn't already end in 's' (big units like "dix
743    /// millionièmes" arrive pre-pluralised from `to_ordinal`). `d == 1`
744    /// (exactly 1, not -1) or `n == 0` short-circuits to the signed cardinal
745    /// before any sign normalisation, and a zero denominator raises
746    /// ZeroDivisionError first ("0/0" raises).
747    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
748        if denominator.is_zero() {
749            return Err(N2WError::ZeroDivision(
750                "denominator must not be zero".into(),
751            ));
752        }
753        if denominator.is_one() || numerator.is_zero() {
754            return self.to_cardinal(numerator);
755        }
756        // is_negative = (numerator < 0) ^ (denominator < 0)
757        let is_negative = numerator.is_negative() ^ denominator.is_negative();
758        let abs_n = numerator.abs();
759        let abs_d = denominator.abs();
760        let den_word = if abs_d == BigInt::from(2) {
761            (if abs_n.is_one() { "demi" } else { "demis" }).to_string()
762        } else if abs_d == BigInt::from(3) {
763            // 'tiers' is invariant in plural.
764            "tiers".to_string()
765        } else if abs_d == BigInt::from(4) {
766            (if abs_n.is_one() { "quart" } else { "quarts" }).to_string()
767        } else {
768            let mut w = self.to_ordinal(&abs_d)?;
769            if !abs_n.is_one() && !w.ends_with('s') {
770                w.push('s');
771            }
772            w
773        };
774        let sign = if is_negative {
775            format!("{} ", self.negword().trim())
776        } else {
777            String::new()
778        };
779        Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
780    }
781
782    // ---- currency -------------------------------------------------------
783    //
784    // Only the two data tables, the class name and FR's plural rule are
785    // language-specific. `to_currency` (via the `super(Num2Word_FR, self)` MRO
786    // skip), `to_cheque`, `_money_verbose`, `_cents_verbose`, `_cents_terse`
787    // and `CURRENCY_PRECISION.get(code, 100)` all resolve to `Num2Word_Base`,
788    // which the trait defaults already mirror — so they are left alone. See
789    // the module docs for why overriding `to_currency` here would be a bug.
790
791    fn lang_name(&self) -> &str {
792        "Num2Word_FR_DZ"
793    }
794
795    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
796        self.currency_forms.get(code)
797    }
798
799    fn currency_adjective(&self, code: &str) -> Option<&str> {
800        self.currency_adjectives.get(code).copied()
801    }
802
803    /// Port of `Num2Word_FR.pluralize`: `forms[0] if abs(n) <= 1 else forms[1]`.
804    ///
805    /// Not `Num2Word_EUR.pluralize` (`forms[0 if n == 1 else 1]`) — FR shadows
806    /// it, and the difference is observable at zero: French takes the singular
807    /// there, so `0.01 EUR` is "zéro euro et un centime", not "zéro euros …".
808    /// `abs()` is faithful rather than load-bearing: `to_currency` only ever
809    /// passes non-negative counts (`abs(val_int)`, and `parse_currency_parts`
810    /// returns the sign separately).
811    ///
812    /// Python indexes the tuple directly, so a one-form entry with `abs(n) > 1`
813    /// would raise IndexError. All three FR_DZ entries carry two forms, so this
814    /// is unreachable — mapped to `Index` rather than panicking so the
815    /// exception type survives if the table ever changes.
816    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
817        let form = if n.abs() <= BigInt::one() { 0 } else { 1 };
818        forms
819            .get(form)
820            .cloned()
821            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
822    }
823}