Skip to main content

num2words2_core/
lang_fr_be.rs

1//! Port of `lang_FR_BE.py` (Belgian French), via its
2//! `Num2Word_FR` → `Num2Word_EUR` → `Num2Word_Base` ancestry.
3//!
4//! Registry check: `__init__.py` maps `"fr_BE"` → `lang_FR_BE.Num2Word_FR_BE()`,
5//! so this file ports `Num2Word_FR_BE` — the class the key actually resolves to.
6//!
7//! Shape: **engine**. `Num2Word_FR_BE.setup()` chains up through
8//! `Num2Word_FR.setup()` → `Num2Word_EUR.setup()`, populating
9//! `high_numwords`/`mid_numwords`/`low_numwords`, then overrides
10//! `mid_numwords` with the Belgian tens. `to_cardinal` is *not* overridden
11//! anywhere in the chain, so `Num2Word_Base.to_cardinal` drives
12//! `splitnum`/`clean`/`merge` — i.e. the `base.rs` default. This file
13//! therefore supplies `cards` + `maxval` + `merge` and overrides only
14//! `to_ordinal` / `to_ordinal_num` / `to_year` / `to_fraction` plus their
15//! float/Decimal entries (all defined on `Num2Word_FR`).
16//!
17//! # What Belgian French changes vs. metropolitan `lang_FR`
18//!
19//! Two deltas, both load-bearing:
20//!
21//! 1. `mid_numwords` gains 90 "nonante" and 70 "septante" (metropolitan FR has
22//!    neither — it builds 70/90 by vigesimal composition off 60), and 80 is
23//!    **"quatre-vingt" without the trailing `s`** (FR uses "quatre-vingts").
24//!    Hence `to_cardinal(80)` == "quatre-vingt" here but "quatre-vingts" in FR.
25//! 2. `merge` joins a trailing 1 with **" et "** (spaces) rather than FR's
26//!    **"-et-"**, and drops FR's `cnum != 80` guard. So 21 → "vingt et un",
27//!    71 → "septante et un", and 81 → "quatre-vingt et un" (FR would emit
28//!    "quatre-vingt-un" for 81, since its guard suppresses the "et").
29//!
30//! # Cards / MAXVAL
31//!
32//! `Num2Word_EUR.setup` builds `high_numwords` = `["cent"] + gen_high_numwords(...)`
33//! = 1 + (10 units × 9 tens) + 9 lows = **100** entries. `set_high_numwords`
34//! then does `cap = 3 + 6*100 = 603` and `zip(high, range(603, 3, -6))` —
35//! `range(603, 3, -6)` yields exactly 100 values (603 down to 9), so `zip`
36//! consumes all of `high` with nothing truncated. Each step inserts *two*
37//! cards on the long scale: `10**n` → word+"illiard" and `10**(n-3)` →
38//! word+"illion". The last pair (word "m", n=9) gives
39//! `10**9` → "milliard" and `10**6` → "million".
40//!
41//! `MAXVAL = 1000 * list(self.cards.keys())[0]`. Python's `cards` is an
42//! `OrderedDict` whose first *inserted* key is `10**603`, which is also its
43//! maximum — so `Cards::highest()` reproduces it — giving `MAXVAL = 10**606`.
44//! Values `>= MAXVAL` raise `OverflowError` in `base.rs`'s
45//! `default_to_cardinal`, matching `Num2Word_Base.to_cardinal`.
46//!
47//! # Faithfully reproduced Python quirks
48//!
49//! This is a port, not a rewrite. The following look wrong but are exactly
50//! what Python emits (all confirmed against the frozen corpus):
51//!
52//! 1. `to_ordinal(0)` == **"zéroième"**. `to_cardinal(0)` is "zéro"; it matches
53//!    neither `ords` key, its last char is "o" (not "e"), and it does not end
54//!    in "ts"/"ents" — so the bare "ième" suffix is glued on. Not a real word.
55//! 2. `to_ordinal_num(0)` == **"0me"** — `Num2Word_FR.to_ordinal_num` only
56//!    special-cases `value == 1` ("1er"); everything else, including 0, gets
57//!    "me".
58//! 3. Large round values pluralize the *ordinal*: `to_ordinal(10**7)` ==
59//!    "dix millionièmes" and `to_ordinal(10**11)` == "cent milliardièmes",
60//!    because the `_BIG_UNITS` branch re-attaches the `s` it stripped. An
61//!    ordinal in the plural is odd, but it is what ships.
62//! 4. `word.endswith("ents")` in the `to_ordinal` fallback is dead code —
63//!    any string ending in "ents" already ends in "ts", so the first
64//!    disjunct always fires first. Kept verbatim.
65//! 5. `to_ordinal(2000)` == "deux millième", not "deux millièmes" — "mille"
66//!    is absent from `_BIG_UNITS`, so it falls through to the generic
67//!    `e`-stripping path and never pluralizes.
68//!
69//! # Errors
70//!
71//! `Num2Word_Base.verify_ordinal` raises **TypeError** for negatives, so
72//! `to_ordinal(-1)` and `to_ordinal_num(-1)` → `N2WError::Type`. `to_cardinal`
73//! accepts negatives (it strips the sign first) and `to_year` delegates
74//! straight to `to_cardinal`, so both are total over the integers below
75//! MAXVAL. No `IndexError`/`KeyError`/`ValueError` crash sites exist in this
76//! chain for integer input.
77//!
78//! # Cross-call mutable state
79//!
80//! None. Nothing in the `FR_BE` → `FR` → `EUR` → `Base` chain stashes a flag
81//! in one method for another to consume (contrast `lang_ES.str_to_number`'s
82//! `_pending_ordinal`). `self.precision` is written by the float paths only,
83//! which are out of scope here.
84
85use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
86use crate::currency::{CurrencyForms, CurrencyValue};
87use crate::floatpath::FloatValue;
88use num_bigint::BigInt;
89use num_integer::Integer;
90use num_traits::{FromPrimitive, One, Signed, Zero};
91use std::collections::HashMap;
92
93/// `Num2Word_FR._BIG_UNITS`, in source order. Order is load-bearing: the
94/// `to_ordinal` loop returns on first match, and the longer names must be
95/// tested before the shorter ones they contain as substrings.
96const BIG_UNITS: [&str; 6] = [
97    "trilliard",
98    "trillion",
99    "billiard",
100    "billion",
101    "milliard",
102    "million",
103];
104
105/// Port of `Num2Word_EUR.gen_high_numwords`.
106fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
107    let mut out: Vec<String> = Vec::new();
108    // Python: [u + t for t in tens for u in units] — `tens` is the outer loop.
109    for t in tens {
110        for u in units {
111            out.push(format!("{}{}", u, t));
112        }
113    }
114    out.reverse();
115
116    const REPLACEMENTS: &[(&str, &str)] = &[
117        ("novemn", "noven"),
118        ("novemo", "novo"),
119        ("octoo", "octo"),
120        ("quintd", "quind"),
121        ("quintn", "quin"),
122        ("quintq", "quinq"),
123        ("quints", "quins"),
124        ("quintt", "quint"),
125        ("quintv", "quinv"),
126        ("septenn", "septen"),
127        ("septent", "sept"),
128        ("sexn", "sen"),
129        ("sexs", "ses"),
130        ("tresd", "tred"),
131        ("tresn", "tren"),
132        ("tress", "tres"),
133        ("tresv", "trev"),
134        ("unno", "uno"),
135    ];
136    for (k, v) in REPLACEMENTS {
137        out = out.iter().map(|o| o.replace(k, v)).collect();
138    }
139    out.extend(lows.iter().map(|s| s.to_string()));
140    out
141}
142
143/// `Num2Word_FR.CURRENCY_FORMS`.
144///
145/// **Not** `Num2Word_EUR`'s table, and deliberately so. `Num2Word_EN.__init__`
146/// mutates `Num2Word_EUR.CURRENCY_FORMS` in place at import time (EUR ->
147/// `("euro", "euros"), ("cent", "cents")`, GBP -> `("pound", "pounds")`), and
148/// every class that inherits that dict without defining its own therefore
149/// serves English forms. `Num2Word_FR` **defines its own** `CURRENCY_FORMS` as
150/// a class attribute, which shadows EUR's in the MRO, so `Num2Word_FR_BE`
151/// (FR_BE -> FR -> EUR -> Base) never sees the mutation. Verified against the
152/// live interpreter: `fr_BE`'s EUR is `("centime", "centimes")`, not
153/// `("cent", "cents")`, and the corpus agrees ("douze euros et trente-quatre
154/// centimes", "douze livres et trente-quatre pence").
155///
156/// Consequence: FR's table is *smaller* than the EN-mutated one. KWD, BHD,
157/// CHF, AED, SGD, ... are simply absent, which drives the two-faced behaviour
158/// documented on `to_currency` below.
159fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
160    const DOLLARS: [&str; 2] = ["dollar", "dollars"];
161    const CENTS: [&str; 2] = ["cent", "cents"];
162    const CENTIMES: [&str; 2] = ["centime", "centimes"];
163
164    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
165    m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &CENTIMES));
166    m.insert("USD", CurrencyForms::new(&DOLLARS, &CENTS));
167    m.insert("FRF", CurrencyForms::new(&["franc", "francs"], &CENTIMES));
168    m.insert("CAD", CurrencyForms::new(&DOLLARS, &CENTS));
169    m.insert("AUD", CurrencyForms::new(&DOLLARS, &CENTS));
170    m.insert("NZD", CurrencyForms::new(&DOLLARS, &CENTS));
171    m.insert("HKD", CurrencyForms::new(&DOLLARS, &CENTS));
172    m.insert("GBP", CurrencyForms::new(&["livre", "livres"], &["penny", "pence"]));
173    // NB: fen/jiaos and yen/yens are FR's own literals, mismatched subunits
174    // and all. Not a transcription slip — see the module quirk list.
175    m.insert("CNY", CurrencyForms::new(&["yuan", "yuans"], &["fen", "jiaos"]));
176    m.insert("JPY", CurrencyForms::new(&["yen", "yens"], &["sen", "sens"]));
177    m.insert("INR", CurrencyForms::new(&["roupie", "roupies"], &["paisa", "paisas"]));
178    m.insert(
179        "RUB",
180        CurrencyForms::new(&["rouble", "roubles"], &["kopeck", "kopecks"]),
181    );
182    m.insert("KRW", CurrencyForms::new(&["won", "wons"], &["jeon", "jeons"]));
183    m.insert(
184        "MXN",
185        CurrencyForms::new(&["peso", "pesos"], &["centavo", "centavos"]),
186    );
187    m
188}
189
190/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged — neither
191/// `Num2Word_FR` nor `Num2Word_FR_BE` defines one, and unlike `CURRENCY_FORMS`
192/// nothing mutates it. Only the codes present in **both** this table and
193/// `build_currency_forms` are reachable (USD, CAD, AUD, INR, JPY, KRW, MXN,
194/// RUB); the rest are dead entries carried for fidelity.
195fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
196    HashMap::from([
197        ("AUD", "Australian"),
198        ("BYN", "Belarusian"),
199        ("CAD", "Canadian"),
200        ("EEK", "Estonian"),
201        ("USD", "US"),
202        ("RUB", "Russian"),
203        ("NOK", "Norwegian"),
204        ("MXN", "Mexican"),
205        ("RON", "Romanian"),
206        ("INR", "Indian"),
207        ("HUF", "Hungarian"),
208        ("ISK", "íslenskar"),
209        ("UZS", "Uzbekistan"),
210        ("SAR", "Saudi"),
211        ("JPY", "Japanese"),
212        ("KRW", "Korean"),
213    ])
214}
215
216/// Python `str(float)`/`repr(float)`, used only to build error-message text
217/// (`errmsg_floatord`, `errmsg_negord`). The corpora compare exception
218/// *types*; the text follows the Python format strings. Whole floats keep
219/// their ".0" ("21.0"), |v| >= 1e16 switches to Python's exponent form
220/// ("1e+20"), and -0.0 keeps its sign.
221fn py_float_str(f: f64) -> String {
222    if f.is_nan() {
223        return "nan".to_string();
224    }
225    if f.is_infinite() {
226        return if f.is_sign_positive() {
227            "inf".to_string()
228        } else {
229            "-inf".to_string()
230        };
231    }
232    let a = f.abs();
233    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
234        // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
235        let s = format!("{:e}", f);
236        if let Some((m, e)) = s.split_once('e') {
237            let (sign, digits) = match e.strip_prefix('-') {
238                Some(d) => ("-", d.to_string()),
239                None => ("+", e.to_string()),
240            };
241            let digits = if digits.len() < 2 {
242                format!("0{}", digits)
243            } else {
244                digits
245            };
246            return format!("{}e{}{}", m, sign, digits);
247        }
248        s
249    } else if f.fract() == 0.0 {
250        // repr keeps the trailing ".0" that Rust's `{}` would drop.
251        format!("{:.1}", f)
252    } else {
253        format!("{}", f)
254    }
255}
256
257/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
258fn py_num_str(v: &FloatValue) -> String {
259    match v {
260        FloatValue::Float { value, .. } => py_float_str(*value),
261        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
262    }
263}
264
265pub struct LangFrBe {
266    cards: Cards,
267    maxval: BigInt,
268    /// `Num2Word_FR.ords`, as a Vec to preserve Python's dict insertion order
269    /// (`cinq` then `neuf`). The `to_ordinal` loop `break`s on first match.
270    ords: Vec<(&'static str, &'static str)>,
271    exclude_title: Vec<String>,
272    /// Built once here, never per call.
273    currency_forms: HashMap<&'static str, CurrencyForms>,
274    currency_adjectives: HashMap<&'static str, &'static str>,
275}
276
277impl Default for LangFrBe {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283impl LangFrBe {
284    pub fn new() -> Self {
285        // --- Num2Word_EUR.setup ---
286        let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
287        let units = [
288            "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
289        ];
290        let tens = [
291            "dec",
292            "vigint",
293            "trigint",
294            "quadragint",
295            "quinquagint",
296            "sexagint",
297            "septuagint",
298            "octogint",
299            "nonagint",
300        ];
301        let mut high = vec!["cent".to_string()];
302        high.extend(gen_high_numwords(&units, &tens, &lows));
303
304        let mut cards = Cards::new();
305
306        // Num2Word_EUR.set_high_numwords: long scale, GIGA_SUFFIX="illiard",
307        // MEGA_SUFFIX="illion". cap = 3 + 6*len(high); zip(high, range(cap, 3, -6)).
308        let cap = 3 + 6 * high.len() as i64;
309        let mut n = cap;
310        for word in high.iter() {
311            if n <= 3 {
312                break; // mirrors range(cap, 3, -6) exhausting / zip truncating
313            }
314            cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}illiard", word));
315            cards.insert(
316                BigInt::from(10u8).pow((n - 3) as u32),
317                format!("{}illion", word),
318            );
319            n -= 6;
320        }
321
322        // --- Num2Word_FR_BE.setup: overrides Num2Word_FR's mid_numwords ---
323        // 90 "nonante" / 70 "septante" are Belgian; 80 is "quatre-vingt"
324        // WITHOUT the `s` that metropolitan FR carries.
325        set_mid_numwords(
326            &mut cards,
327            &[
328                (1000, "mille"),
329                (100, "cent"),
330                (90, "nonante"),
331                (80, "quatre-vingt"),
332                (70, "septante"),
333                (60, "soixante"),
334                (50, "cinquante"),
335                (40, "quarante"),
336                (30, "trente"),
337            ],
338        );
339
340        // --- Num2Word_FR.setup: low_numwords (21 entries → values 20..0) ---
341        set_low_numwords(
342            &mut cards,
343            &[
344                "vingt", "dix-neuf", "dix-huit", "dix-sept", "seize", "quinze", "quatorze",
345                "treize", "douze", "onze", "dix", "neuf", "huit", "sept", "six", "cinq", "quatre",
346                "trois", "deux", "un", "zéro",
347            ],
348        );
349
350        // MAXVAL = 1000 * first-inserted card key = 1000 * 10**603 = 10**606.
351        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
352
353        LangFrBe {
354            cards,
355            maxval,
356            ords: vec![("cinq", "cinquième"), ("neuf", "neuvième")],
357            exclude_title: vec!["et".into(), "virgule".into(), "moins".into()],
358            currency_forms: build_currency_forms(),
359            currency_adjectives: build_currency_adjectives(),
360        }
361    }
362
363    /// `Num2Word_Base.verify_ordinal`. For integral input only the negative
364    /// check can fire; Python raises TypeError (not ValueError) there.
365    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
366        if value.is_negative() {
367            return Err(N2WError::Type(format!(
368                "Cannot treat negative num {} as ordinal.",
369                value
370            )));
371        }
372        Ok(())
373    }
374
375    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
376    /// Python's order:
377    ///
378    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
379    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
380    ///
381    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
382    /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
383    /// "zéroième", not an error. A negative fractional value (-1.5) fails
384    /// check 1 first, so it raises the *float* message, as Python does.
385    /// Returns the integral value for the integer-path continuation.
386    /// (NaN/±inf would make `int(value)` raise before either check, but the
387    /// dispatcher keeps those on the Python side; `as_whole_int` maps them to
388    /// the float-message arm, which is unreachable for them in practice.)
389    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
390        match value.as_whole_int() {
391            Some(i) => {
392                if i.is_negative() {
393                    Err(N2WError::Type(format!(
394                        "Cannot treat negative num {} as ordinal.",
395                        py_num_str(value)
396                    )))
397                } else {
398                    Ok(i)
399                }
400            }
401            None => Err(N2WError::Type(format!(
402                "Cannot treat float {} as ordinal.",
403                py_num_str(value)
404            ))),
405        }
406    }
407}
408
409impl Lang for LangFrBe {
410    /// This language's own `to_currency(currency=...)` default,
411    /// read from the live Python signature. Only 44 of 156 use EUR.
412    fn default_currency(&self) -> &str {
413        "EUR"
414    }
415
416    /// This language's own `to_currency(separator=...)` default,
417    /// read from the live Python signature. Base's is ",", but only
418    /// 36 of 149 languages actually use it — most default to " " or a
419    /// conjunction, so inheriting Base's comma silently corrupts them.
420    fn default_separator(&self) -> &str {
421        " et"
422    }
423
424    fn cards(&self) -> &Cards {
425        &self.cards
426    }
427    fn maxval(&self) -> &BigInt {
428        &self.maxval
429    }
430    fn negword(&self) -> &str {
431        "moins "
432    }
433    fn pointword(&self) -> &str {
434        "virgule"
435    }
436    fn exclude_title(&self) -> &[String] {
437        &self.exclude_title
438    }
439
440    /// Port of `Num2Word_FR_BE.merge`.
441    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
442        let (ctext, cnum) = l;
443        let (ntext, nnum) = r;
444        let mut ctext = ctext.to_string();
445        let mut ntext = ntext.to_string();
446
447        let hundred = BigInt::from(100);
448        let thousand = BigInt::from(1000);
449        let million = BigInt::from(1_000_000);
450
451        if cnum.is_one() {
452            if nnum < &million {
453                return (ntext, nnum.clone());
454            }
455            // NB: no `else` fall-through in Python either — when cnum == 1 and
456            // nnum >= 1_000_000 the `else` block is skipped entirely and we
457            // drop straight to the comparison ladder. Hence "un million".
458        } else {
459            // Drop the trailing 's' of 'cents'/'vingts' before a following
460            // word: "sept cent vingt-neuf", not "sept cents vingt-neuf".
461            // Python's `%` floors toward -inf; `mod_floor` matches it. (cnum
462            // is non-negative here in practice, but (cnum-80) can be, e.g.,
463            // -76 for cnum=4 → 24 under Python semantics, 24 under mod_floor.)
464            let a = (cnum - BigInt::from(80)).mod_floor(&hundred).is_zero();
465            let b = (cnum % &hundred).is_zero() && cnum < &thousand;
466            if (a || b) && nnum < &million && ctext.chars().last() == Some('s') {
467                ctext.pop();
468            }
469            if cnum < &thousand
470                && nnum != &thousand
471                && last_char_is_not_s(&ntext)
472                && (nnum % &hundred).is_zero()
473            {
474                ntext.push('s');
475            }
476        }
477
478        if nnum < cnum && cnum < &hundred {
479            if (nnum % BigInt::from(10)).is_one() {
480                // Belgian: " et " with spaces, and NO `cnum != 80` guard —
481                // 81 → "quatre-vingt et un" (metropolitan FR: "quatre-vingt-un").
482                return (format!("{} et {}", ctext, ntext), cnum + nnum);
483            }
484            return (format!("{}-{}", ctext, ntext), cnum + nnum);
485        }
486        if nnum > cnum {
487            return (format!("{} {}", ctext, ntext), cnum * nnum);
488        }
489        (format!("{} {}", ctext, ntext), cnum + nnum)
490    }
491
492    /// Port of `Num2Word_FR.to_ordinal`.
493    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
494        self.verify_ordinal(value)?;
495        if value.is_one() {
496            return Ok("premier".to_string());
497        }
498        let word = self.to_cardinal(value)?;
499
500        // Big-unit ordinals: drop a leading "un ", strip trailing 's', append
501        // "ième", then re-pluralize if the count was > 1.
502        for unit in BIG_UNITS.iter() {
503            if word == format!("un {}", unit) || word.ends_with(&format!(" {}s", unit)) {
504                let plural = word.ends_with('s');
505                // Python's rstrip("s") removes ALL trailing 's', not just one.
506                let stripped = word.trim_end_matches('s');
507                let stripped = stripped.strip_prefix("un ").unwrap_or(stripped);
508                return Ok(format!(
509                    "{}ième{}",
510                    stripped,
511                    if plural { "s" } else { "" }
512                ));
513            }
514            if word == *unit {
515                return Ok(format!("{}ième", unit));
516            }
517        }
518
519        // Python's `for ... else`: the else arm runs only if no `break` fired.
520        let mut word = word;
521        let mut matched = false;
522        for (src, repl) in self.ords.iter() {
523            if word.ends_with(src) {
524                // `src` is ASCII ("cinq"/"neuf") and `word` ends with it, so
525                // the split point is guaranteed to be a char boundary.
526                let keep = word.len() - src.len();
527                word = format!("{}{}", &word[..keep], repl);
528                matched = true;
529                break;
530            }
531        }
532        if !matched {
533            // Python: word[-1] == "e" → word[:-1]. String::pop is char-aware,
534            // matching Python's character-wise slicing.
535            if word.ends_with('e') {
536                word.pop();
537            }
538            // "deux cents" → "deux cent" + "ième". The `endswith("ents")`
539            // disjunct is dead code in Python (see module docs); kept verbatim.
540            if word.ends_with("ts") || word.ends_with("ents") {
541                word.pop();
542            }
543            word.push_str("ième");
544        }
545        Ok(word)
546    }
547
548    /// Port of `Num2Word_FR.to_ordinal_num`: "1er", everything else "<n>me".
549    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
550        self.verify_ordinal(value)?;
551        let mut out = value.to_string();
552        out.push_str(if value.is_one() { "er" } else { "me" });
553        Ok(out)
554    }
555
556    /// Port of `Num2Word_FR.to_year`: `return self.to_cardinal(int(val))` —
557    /// no century splitting, no BC/AD suffix. Negative years just inherit
558    /// to_cardinal's "moins " prefix (year -500 → "moins cinq cents").
559    fn to_year(&self, value: &BigInt) -> Result<String> {
560        self.to_cardinal(value)
561    }
562
563    // ---- float/Decimal entries -------------------------------------------
564    //
565    // Python's dispatcher hands floats/Decimals straight to the converter
566    // methods, so `verify_ordinal`'s float/negative checks and `to_year`'s
567    // `int()` truncation become reachable here — unlike on the BigInt hooks
568    // above, where they are dead code. `to_cardinal` needs no override: the
569    // FR_BE → FR → EUR → Base chain inherits base's `assert int(value) ==
570    // value` routing, which is exactly the trait default (whole -> int path).
571
572    /// `Num2Word_FR.to_ordinal(float/Decimal)`: `verify_ordinal`, then the
573    /// integer path. Whole values ordinalise (5.0 -> "cinquième", 1.0 ->
574    /// "premier" via the `value == 1` special case, 21.0 -> the Belgian
575    /// "vingt et unième", Decimal("1E+2") -> "centième", 1e+16 -> the plural
576    /// quirk "dix billiardièmes"); fractional or negative values raise
577    /// TypeError.
578    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
579        let i = self.verify_ordinal_float(value)?;
580        self.to_ordinal(&i)
581    }
582
583    /// `Num2Word_FR.to_ordinal_num(float/Decimal)`:
584    /// `str(value) + ("er" if value == 1 else "me")`. The numeric half is
585    /// Python's `str(value)` (`repr_str`), so 1.0 yields "1.0er",
586    /// Decimal("5.00") "5.00me", -0.0 "-0.0me" and Decimal("1E+20")
587    /// "1E+20me". No overflow is possible — Python never words the value.
588    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
589        let i = self.verify_ordinal_float(value)?;
590        Ok(format!(
591            "{}{}",
592            repr_str,
593            if i.is_one() { "er" } else { "me" }
594        ))
595    }
596
597    /// `Num2Word_FR.to_year(float/Decimal)`: `self.to_cardinal(int(val))` —
598    /// `int()` truncates toward zero, so 2.5 -> "deux", -1.5 -> "moins un",
599    /// 0.5 -> "zéro". No non-integer guard (contrast EN's TypeError):
600    /// fractional values are silently truncated, floats and Decimals alike.
601    /// NaN/±inf floats reproduce `int()`'s ValueError/OverflowError, though
602    /// the dispatcher keeps those on the Python side.
603    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
604        let i = match value {
605            FloatValue::Float { value: f, .. } => {
606                if f.is_nan() {
607                    return Err(N2WError::Value(
608                        "cannot convert float NaN to integer".into(),
609                    ));
610                }
611                if f.is_infinite() {
612                    return Err(N2WError::Overflow(
613                        "cannot convert float infinity to integer".into(),
614                    ));
615                }
616                // Exact: every whole f64 is exactly representable.
617                BigInt::from_f64(f.trunc()).ok_or_else(|| {
618                    N2WError::Value("cannot convert float NaN to integer".into())
619                })?
620            }
621            // int(Decimal) truncates toward zero.
622            FloatValue::Decimal { value: d, .. } => d.with_scale(0).as_bigint_and_exponent().0,
623        };
624        self.to_cardinal(&i)
625    }
626
627    // ---- fractions --------------------------------------------------------
628
629    /// Port of `Num2Word_FR.to_fraction` (issue #584), inherited unchanged by
630    /// `Num2Word_FR_BE`: idiomatic demi/tiers/quart for denominators 2/3/4 —
631    /// "tiers" is invariant in the plural ("1000000/3" -> "un million
632    /// tiers"), demi/quart take -s. Every other denominator is
633    /// ordinal-as-noun, appending "s" only when the numerator isn't 1 *and*
634    /// the ordinal doesn't already end in 's' (big units like "dix
635    /// millionièmes" arrive pre-pluralised from `to_ordinal`). `d == 1`
636    /// (exactly 1, not -1) or `n == 0` short-circuits to the signed cardinal
637    /// before any sign normalisation, and a zero denominator raises
638    /// ZeroDivisionError first ("0/0" raises).
639    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
640        if denominator.is_zero() {
641            return Err(N2WError::ZeroDivision(
642                "denominator must not be zero".into(),
643            ));
644        }
645        if denominator.is_one() || numerator.is_zero() {
646            return self.to_cardinal(numerator);
647        }
648        // is_negative = (numerator < 0) ^ (denominator < 0)
649        let is_negative = numerator.is_negative() ^ denominator.is_negative();
650        let abs_n = numerator.abs();
651        let abs_d = denominator.abs();
652        let den_word = if abs_d == BigInt::from(2) {
653            (if abs_n.is_one() { "demi" } else { "demis" }).to_string()
654        } else if abs_d == BigInt::from(3) {
655            // 'tiers' is invariant in plural.
656            "tiers".to_string()
657        } else if abs_d == BigInt::from(4) {
658            (if abs_n.is_one() { "quart" } else { "quarts" }).to_string()
659        } else {
660            let mut w = self.to_ordinal(&abs_d)?;
661            if !abs_n.is_one() && !w.ends_with('s') {
662                w.push('s');
663            }
664            w
665        };
666        let sign = if is_negative {
667            format!("{} ", self.negword().trim())
668        } else {
669            String::new()
670        };
671        Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
672    }
673
674    // ---- currency ----------------------------------------------------
675
676    fn lang_name(&self) -> &str {
677        "Num2Word_FR_BE"
678    }
679
680    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
681        self.currency_forms.get(code)
682    }
683
684    fn currency_adjective(&self, code: &str) -> Option<&str> {
685        self.currency_adjectives.get(code).copied()
686    }
687
688    // `currency_precision` is deliberately NOT overridden. No class in the
689    // FR_BE → FR → EUR → Base chain defines `CURRENCY_PRECISION`, so it
690    // resolves to `Num2Word_Base.CURRENCY_PRECISION == {}` and every code
691    // takes the `.get(code, 100)` default of 100 — including JPY. EN *does*
692    // define 1000/1 precisions, but it **rebinds** the attribute in
693    // `__init__` (`self.CURRENCY_PRECISION = {...}`, an instance attribute)
694    // rather than mutating the shared class dict, so unlike CURRENCY_FORMS it
695    // cannot leak here. Hence JPY divides by 100 and keeps its cents segment:
696    // 12.34 JPY → "douze yens et trente-quatre sens", not "douze yens".
697    // That also means `default_to_currency`'s `divisor == 1` pre-rounding
698    // branch is unreachable for this language.
699
700    /// `Num2Word_FR.pluralize`: `forms[0] if abs(n) <= 1 else forms[1]`.
701    ///
702    /// Overrides `Num2Word_EUR.pluralize` (`forms[0 if n == 1 else 1]`) so 0
703    /// takes the singular as well: "zéro euro et un centime", not "zéro euros".
704    /// Python indexes the tuple directly, so a one-form entry with `abs(n) > 1`
705    /// raises IndexError; every entry in FR's table has two forms, so that is
706    /// unreachable — mapped to `Index` rather than panicking in case it ever
707    /// is not.
708    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
709        let form = if n.abs() <= BigInt::one() { 0 } else { 1 };
710        forms
711            .get(form)
712            .cloned()
713            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
714    }
715
716    /// Port of `Num2Word_FR.to_currency`, which splits hard on `isinstance(val,
717    /// int)` and hands only the float path to `super()`.
718    ///
719    /// The int branch is not a shortcut through Base — it diverges in two
720    /// observable ways, both reproduced here:
721    ///
722    /// 1. **An unknown currency code does not raise.** Base's int path lets the
723    ///    `KeyError` become `NotImplementedError`; FR catches it and returns a
724    ///    bare `to_cardinal(val)` — the number with no currency word at all.
725    ///    So `to_currency(100, "KWD")` == "cent" and `to_currency(-100, "KWD")`
726    ///    == "moins cent", while the *float* path for the same code raises
727    ///    NotImplementedError from Base. The corpus pins both halves: KWD/BHD/CHF
728    ///    ints give "zéro"/"un"/"deux"/"cent"/"un million", their floats give
729    ///    NotImplementedError.
730    /// 2. **`adjective` is ignored.** FR's int branch never calls
731    ///    `prefix_currency`, so `to_currency(2, "USD", adjective=True)` is
732    ///    "deux dollars" — yet `to_currency(2.0, "USD", adjective=True)` goes to
733    ///    Base and *is* prefixed: "deux US dollars et zéro cent". Same flag,
734    ///    same code, opposite answers, decided purely by int vs float.
735    ///
736    /// The int branch also reimplements pluralization inline rather than calling
737    /// `self.pluralize`, and uses `self.to_cardinal` where Base would use
738    /// `_money_verbose`. Neither changes output here (`_money_verbose` is
739    /// unoverridden, and the inline rule matches `pluralize` for every 2-form
740    /// entry), but the inline `cr1[1] if len(cr1) > 1 else cr1[0]` is *softer*
741    /// than `pluralize`'s bare `forms[1]`: on a hypothetical 1-form entry it
742    /// would return the singular where `pluralize` raises IndexError.
743    fn to_currency(
744        &self,
745        val: &CurrencyValue,
746        currency: &str,
747        cents: bool,
748        separator: Option<&str>,
749        adjective: bool,
750    ) -> Result<String> {
751        if let CurrencyValue::Int(v) = val {
752            let cr1 = match self.currency_forms.get(currency) {
753                Some(forms) => &forms.unit,
754                // `except KeyError: return self.to_cardinal(val)` — `val`, the
755                // *signed* original, so to_cardinal supplies "moins " itself.
756                None => return self.to_cardinal(v),
757            };
758
759            let minus_str = if v.is_negative() {
760                format!("{} ", self.negword().trim())
761            } else {
762                String::new()
763            };
764            let abs_val = v.abs();
765            let money_str = self.to_cardinal(&abs_val)?;
766
767            // `cr1[0] if abs_val <= 1 else (cr1[1] if len(cr1) > 1 else cr1[0])`
768            let currency_str = if abs_val <= BigInt::one() {
769                cr1.first()
770            } else {
771                cr1.get(1).or_else(|| cr1.first())
772            }
773            .cloned()
774            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
775
776            return Ok(format!("{}{} {}", minus_str, money_str, currency_str));
777        }
778
779        // Floats/Decimals: `super(Num2Word_FR, self).to_currency(...)`.
780        // `Num2Word_EUR` does not override `to_currency`, so super() resolves
781        // straight to `Num2Word_Base.to_currency` — i.e. this default.
782        crate::currency::default_to_currency(
783            self,
784            val,
785            currency,
786            cents,
787            separator.unwrap_or(self.default_separator()),
788            adjective,
789        )
790    }
791
792    // `to_cheque` is NOT overridden: nothing in the FR_BE → FR → EUR chain
793    // defines it, so `Num2Word_Base.to_cheque` runs and `default_to_cheque`
794    // reproduces it given the right forms + lang_name. It always takes the
795    // last (plural) unit form and always divides by 100 here, so JPY yields
796    // "MILLE DEUX CENT TRENTE-QUATRE AND 56/100 YENS" and the codes missing
797    // from FR's table raise NotImplementedError — unlike `to_currency`'s int
798    // path, `to_cheque` has no bare-cardinal fallback.
799}
800
801/// Python: `ntext[-1] != "s"` — compares the last *character*, not byte.
802fn last_char_is_not_s(s: &str) -> bool {
803    s.chars().last() != Some('s')
804}