Skip to main content

num2words2_core/
lang_fr.rs

1//! Port of `lang_FR.py` (via `lang_EUR` → `Num2Word_Base`).
2//!
3//! Engine-style language: FR supplies `mid_numwords`/`low_numwords` + `merge`
4//! and inherits `Num2Word_EUR.set_high_numwords`, letting the base engine's
5//! `splitnum`/`clean` drive `to_cardinal`.
6//!
7//! Unlike EN (which overrides `set_high_numwords` to the short scale), FR uses
8//! EUR's **long scale**: each Latin stem yields a `-illiard` at 10^n and an
9//! `-illion` at 10^(n-3), stepping by 6. So 10^9 = "milliard", 10^12 =
10//! "billion", 10^15 = "billiard", 10^18 = "trillion", … up to 10^603 =
11//! "centilliard". MAXVAL is therefore 1000 * 10^603 = 10^606.
12
13use crate::base::{clean, set_low_numwords, set_mid_numwords, splitnum, Cards, Lang, N2WError, Node, Result};
14use crate::currency::{CurrencyForms, CurrencyValue};
15use crate::floatpath::FloatValue;
16use num_bigint::BigInt;
17use num_integer::Integer;
18use num_traits::{FromPrimitive, One, Signed, Zero};
19use std::collections::HashMap;
20
21/// Port of `Num2Word_EUR.gen_high_numwords`.
22///
23/// Duplicated locally rather than imported from `lang_en` to keep this file
24/// self-contained (the registry in `lib.rs` is generated mechanically).
25fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
26    let mut out: Vec<String> = Vec::new();
27    // Python: [u + t for t in tens for u in units] — `tens` is the outer loop.
28    for t in tens {
29        for u in units {
30            out.push(format!("{}{}", u, t));
31        }
32    }
33    out.reverse();
34
35    const REPLACEMENTS: &[(&str, &str)] = &[
36        ("novemn", "noven"),
37        ("novemo", "novo"),
38        ("octoo", "octo"),
39        ("quintd", "quind"),
40        ("quintn", "quin"),
41        ("quintq", "quinq"),
42        ("quints", "quins"),
43        ("quintt", "quint"),
44        ("quintv", "quinv"),
45        ("septenn", "septen"),
46        ("septent", "sept"),
47        ("sexn", "sen"),
48        ("sexs", "ses"),
49        ("tresd", "tred"),
50        ("tresn", "tren"),
51        ("tress", "tres"),
52        ("tresv", "trev"),
53        ("unno", "uno"),
54    ];
55    for (k, v) in REPLACEMENTS {
56        out = out.iter().map(|o| o.replace(k, v)).collect();
57    }
58    out.extend(lows.iter().map(|s| s.to_string()));
59    out
60}
61
62/// `Num2Word_FR._BIG_UNITS`, in source order — the order is load-bearing
63/// because "trilliard" must be tested before "trillion", etc.
64const BIG_UNITS: [&str; 6] = [
65    "trilliard",
66    "trillion",
67    "billiard",
68    "billion",
69    "milliard",
70    "million",
71];
72
73/// `Num2Word_FR.ords`. A slice (not a map) to preserve Python's dict
74/// insertion order, which `to_ordinal` iterates.
75const ORDS: [(&str, &str); 2] = [("cinq", "cinquième"), ("neuf", "neuvième")];
76
77/// `Num2Word_FR.CURRENCY_FORMS`, transcribed from the class body verbatim.
78///
79/// FR declares its **own** `CURRENCY_FORMS`, so — unlike the 16 classes that
80/// inherit the dict — it never sees the entries `Num2Word_EN.__init__` mutates
81/// into `Num2Word_EUR.CURRENCY_FORMS`. Confirmed against the live interpreter:
82/// FR's EUR is `("euro", "euros")` / `("centime", "centimes")` because that is
83/// FR's own literal, not because English rewrote it. Nothing here is inherited.
84///
85/// Consequences worth naming, since they look like typos and are not:
86///   * CNY's subunits are `("fen", "jiaos")` — two *different* units, so 0.01
87///     CNY is "un fen" but 0.34 is "trente-quatre jiaos". Upstream data bug.
88///   * JPY/KRW carry sen/jeon subunits and FR has no `CURRENCY_PRECISION`
89///     override, so they are ordinary 100-subunit currencies here.
90///   * KWD/BHD/CHF are absent, which is what drives them down the
91///     KeyError paths in `to_currency` / `to_cheque` (see those methods).
92fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
93    const DOLLARS: [&str; 2] = ["dollar", "dollars"];
94    const CENTS: [&str; 2] = ["cent", "cents"];
95    const CENTIMES: [&str; 2] = ["centime", "centimes"];
96
97    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
98    m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &CENTIMES));
99    m.insert("USD", CurrencyForms::new(&DOLLARS, &CENTS));
100    m.insert("FRF", CurrencyForms::new(&["franc", "francs"], &CENTIMES));
101    m.insert("CAD", CurrencyForms::new(&DOLLARS, &CENTS));
102    m.insert("AUD", CurrencyForms::new(&DOLLARS, &CENTS));
103    m.insert("NZD", CurrencyForms::new(&DOLLARS, &CENTS));
104    m.insert("HKD", CurrencyForms::new(&DOLLARS, &CENTS));
105    m.insert("GBP", CurrencyForms::new(&["livre", "livres"], &["penny", "pence"]));
106    m.insert("CNY", CurrencyForms::new(&["yuan", "yuans"], &["fen", "jiaos"]));
107    m.insert("JPY", CurrencyForms::new(&["yen", "yens"], &["sen", "sens"]));
108    m.insert("INR", CurrencyForms::new(&["roupie", "roupies"], &["paisa", "paisas"]));
109    m.insert("RUB", CurrencyForms::new(&["rouble", "roubles"], &["kopeck", "kopecks"]));
110    m.insert("KRW", CurrencyForms::new(&["won", "wons"], &["jeon", "jeons"]));
111    m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &["centavo", "centavos"]));
112    m
113}
114
115/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged — FR declares none
116/// and `Num2Word_EN.__init__` never touches this dict (it only mutates
117/// `CURRENCY_FORMS`), so the class-body literal is what runs. Verified against
118/// the live interpreter.
119///
120/// Half of these codes (BYN, EEK, HUF, ISK, NOK, RON, SAR, UZS) have no entry
121/// in FR's `CURRENCY_FORMS`, so they are unreachable: the forms lookup decides
122/// the outcome long before an adjective could be prefixed. Kept anyway — the
123/// dict is ported data, not a curated list.
124fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
125    [
126        ("AUD", "Australian"),
127        ("BYN", "Belarusian"),
128        ("CAD", "Canadian"),
129        ("EEK", "Estonian"),
130        ("USD", "US"),
131        ("RUB", "Russian"),
132        ("NOK", "Norwegian"),
133        ("MXN", "Mexican"),
134        ("RON", "Romanian"),
135        ("INR", "Indian"),
136        ("HUF", "Hungarian"),
137        ("ISK", "íslenskar"),
138        ("UZS", "Uzbekistan"),
139        ("SAR", "Saudi"),
140        ("JPY", "Japanese"),
141        ("KRW", "Korean"),
142    ]
143    .into_iter()
144    .collect()
145}
146
147/// Python `str(float)`/`repr(float)`, used only to build error-message text
148/// (`errmsg_floatord`, `errmsg_negord`). The corpora compare exception
149/// *types*; the text follows the Python format strings. Whole floats keep
150/// their ".0" ("21.0"), |v| >= 1e16 switches to Python's exponent form
151/// ("1e+20"), and -0.0 keeps its sign.
152fn py_float_str(f: f64) -> String {
153    if f.is_nan() {
154        return "nan".to_string();
155    }
156    if f.is_infinite() {
157        return if f.is_sign_positive() {
158            "inf".to_string()
159        } else {
160            "-inf".to_string()
161        };
162    }
163    let a = f.abs();
164    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
165        // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
166        let s = format!("{:e}", f);
167        if let Some((m, e)) = s.split_once('e') {
168            let (sign, digits) = match e.strip_prefix('-') {
169                Some(d) => ("-", d.to_string()),
170                None => ("+", e.to_string()),
171            };
172            let digits = if digits.len() < 2 {
173                format!("0{}", digits)
174            } else {
175                digits
176            };
177            return format!("{}e{}{}", m, sign, digits);
178        }
179        s
180    } else if f.fract() == 0.0 {
181        // repr keeps the trailing ".0" that Rust's `{}` would drop.
182        format!("{:.1}", f)
183    } else {
184        format!("{}", f)
185    }
186}
187
188/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
189fn py_num_str(v: &FloatValue) -> String {
190    match v {
191        FloatValue::Float { value, .. } => py_float_str(*value),
192        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
193    }
194}
195
196pub struct LangFr {
197    cards: Cards,
198    maxval: BigInt,
199    exclude_title: Vec<String>,
200    currency_forms: HashMap<&'static str, CurrencyForms>,
201    currency_adjectives: HashMap<&'static str, &'static str>,
202}
203
204impl Default for LangFr {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210impl LangFr {
211    pub fn new() -> Self {
212        // Num2Word_EUR.setup
213        let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
214        let units = [
215            "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
216        ];
217        let tens = [
218            "dec",
219            "vigint",
220            "trigint",
221            "quadragint",
222            "quinquagint",
223            "sexagint",
224            "septuagint",
225            "octogint",
226            "nonagint",
227        ];
228        let mut high = vec!["cent".to_string()];
229        high.extend(gen_high_numwords(&units, &tens, &lows));
230
231        let mut cards = Cards::new();
232
233        // Num2Word_EUR.set_high_numwords — long scale:
234        //   cap = 3 + 6 * len(high)                        # 603
235        //   for word, n in zip(high, range(cap, 3, -6)):
236        //       cards[10**n]     = word + GIGA_SUFFIX      # "illiard"
237        //       cards[10**(n-3)] = word + MEGA_SUFFIX      # "illion"
238        // `high` has 100 entries and range(603, 3, -6) has 100 values, so the
239        // zip consumes both exactly.
240        let cap: i64 = 3 + 6 * high.len() as i64;
241        let mut n = cap;
242        for word in high.iter() {
243            if n <= 3 {
244                break;
245            }
246            cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}illiard", word));
247            cards.insert(
248                BigInt::from(10u8).pow((n - 3) as u32),
249                format!("{}illion", word),
250            );
251            n -= 6;
252        }
253
254        // Note: no cards for 70 or 90 — French builds those by merging
255        // (soixante-dix, quatre-vingt-dix).
256        set_mid_numwords(
257            &mut cards,
258            &[
259                (1000, "mille"),
260                (100, "cent"),
261                (80, "quatre-vingts"),
262                (60, "soixante"),
263                (50, "cinquante"),
264                (40, "quarante"),
265                (30, "trente"),
266            ],
267        );
268        set_low_numwords(
269            &mut cards,
270            &[
271                "vingt", "dix-neuf", "dix-huit", "dix-sept", "seize", "quinze", "quatorze",
272                "treize", "douze", "onze", "dix", "neuf", "huit", "sept", "six", "cinq", "quatre",
273                "trois", "deux", "un", "zéro",
274            ],
275        );
276
277        // MAXVAL = 1000 * list(self.cards.keys())[0]  ->  1000 * 10**603
278        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
279
280        LangFr {
281            cards,
282            maxval,
283            exclude_title: vec!["et".into(), "virgule".into(), "moins".into()],
284            // Built once here, never per call — `to_currency` only reads them.
285            currency_forms: build_currency_forms(),
286            currency_adjectives: build_currency_adjectives(),
287        }
288    }
289
290    /// `Num2Word_Base.verify_ordinal`. The float check is unreachable for
291    /// BigInt input, so only the negative check survives.
292    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
293        if value.is_negative() {
294            return Err(N2WError::Type(format!(
295                "Cannot treat negative num {} as ordinal.",
296                value
297            )));
298        }
299        Ok(())
300    }
301
302    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
303    /// Python's order:
304    ///
305    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
306    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
307    ///
308    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
309    /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
310    /// "zéroième", not an error. A negative fractional value (-1.5) fails
311    /// check 1 first, so it raises the *float* message, as Python does.
312    /// Returns the integral value for the integer-path continuation.
313    /// (NaN/±inf would make `int(value)` raise before either check, but the
314    /// dispatcher keeps those on the Python side; `as_whole_int` maps them to
315    /// the float-message arm, which is unreachable for them in practice.)
316    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
317        match value.as_whole_int() {
318            Some(i) => {
319                if i.is_negative() {
320                    Err(N2WError::Type(format!(
321                        "Cannot treat negative num {} as ordinal.",
322                        py_num_str(value)
323                    )))
324                } else {
325                    Ok(i)
326                }
327            }
328            None => Err(N2WError::Type(format!(
329                "Cannot treat float {} as ordinal.",
330                py_num_str(value)
331            ))),
332        }
333    }
334}
335
336impl Lang for LangFr {
337    /// This language's own `to_currency(currency=...)` default,
338    /// read from the live Python signature. Only 44 of 156 use EUR.
339    fn default_currency(&self) -> &str {
340        "EUR"
341    }
342
343    /// This language's own `to_currency(separator=...)` default,
344    /// read from the live Python signature. Base's is ",", but only
345    /// 36 of 149 languages actually use it — most default to " " or a
346    /// conjunction, so inheriting Base's comma silently corrupts them.
347    fn default_separator(&self) -> &str {
348        " et"
349    }
350
351    fn cards(&self) -> &Cards {
352        &self.cards
353    }
354    fn maxval(&self) -> &BigInt {
355        &self.maxval
356    }
357    fn negword(&self) -> &str {
358        "moins "
359    }
360    fn pointword(&self) -> &str {
361        "virgule"
362    }
363    fn exclude_title(&self) -> &[String] {
364        &self.exclude_title
365    }
366
367    /// `Num2Word_Base.to_cardinal` for integral input.
368    ///
369    /// Reimplemented rather than delegating to `base::default_to_cardinal`
370    /// solely because FR overrides `errmsg_toobig` with a French message.
371    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
372        let mut out = String::new();
373        let mut v = value.clone();
374        if v.is_negative() {
375            v = v.abs();
376            out = format!("{} ", self.negword().trim());
377        }
378
379        if &v >= self.maxval() {
380            return Err(N2WError::Overflow(format!(
381                "Nombre trop grand pour être converti en mots (abs({}) > {}).",
382                v,
383                self.maxval()
384            )));
385        }
386
387        let tree = splitnum(self, &v).ok_or_else(|| {
388            N2WError::Overflow(format!(
389                "Nombre trop grand pour être converti en mots (abs({}) > {}).",
390                v,
391                self.maxval()
392            ))
393        })?;
394        let words = match clean(self, tree) {
395            Node::Leaf(t, _) => t,
396            Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
397        };
398        Ok(self.title(&format!("{}{}", out, words)))
399    }
400
401    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
402        let (ctext_in, cnum) = l;
403        let (ntext_in, nnum) = r;
404        let mut ctext = ctext_in.to_string();
405        let mut ntext = ntext_in.to_string();
406
407        let hundred = BigInt::from(100);
408        let thousand = BigInt::from(1000);
409        let million = BigInt::from(1_000_000);
410
411        if cnum.is_one() {
412            if nnum < &million {
413                return (ntext, nnum.clone());
414            }
415            // NB: when nnum >= 1_000_000 Python falls through the `cnum == 1`
416            // branch without returning *and* without running the `else` body,
417            // so ctext/ntext stay untouched. Hence "un million" (no -s).
418        } else {
419            // if ((not (cnum - 80) % 100 or (not cnum % 100 and cnum < 1000))
420            //     and nnum < 1000000 and ctext[-1] == "s"):
421            //     ctext = ctext[:-1]
422            //
423            // Python's % on a negative left operand floors (e.g. (20-80) % 100
424            // == 40), so mod_floor is used rather than Rust's remainder. Only
425            // the ==0 test matters here, where the two agree, but keep it exact.
426            let c_minus_80 = cnum.clone() - BigInt::from(80);
427            let cond = (c_minus_80.mod_floor(&hundred).is_zero()
428                || (cnum.mod_floor(&hundred).is_zero() && cnum < &thousand))
429                && nnum < &million
430                && ctext.chars().last() == Some('s');
431            if cond {
432                ctext.pop();
433            }
434
435            // if cnum < 1000 and nnum != 1000 and ntext[-1] != "s" and not nnum % 100:
436            //     ntext += "s"
437            // The `nnum != 1000` guard is what keeps "deux mille" singular.
438            if cnum < &thousand
439                && nnum != &thousand
440                && ntext.chars().last() != Some('s')
441                && nnum.mod_floor(&hundred).is_zero()
442            {
443                ntext.push('s');
444            }
445        }
446
447        // if nnum < cnum < 100  (Python chained comparison)
448        if nnum < cnum && cnum < &hundred {
449            if nnum.mod_floor(&BigInt::from(10)).is_one() && cnum != &BigInt::from(80) {
450                return (format!("{}-et-{}", ctext, ntext), cnum + nnum);
451            }
452            return (format!("{}-{}", ctext, ntext), cnum + nnum);
453        }
454        if nnum > cnum {
455            return (format!("{} {}", ctext, ntext), cnum * nnum);
456        }
457        (format!("{} {}", ctext, ntext), cnum + nnum)
458    }
459
460    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
461        self.verify_ordinal(value)?;
462        if value.is_one() {
463            return Ok("premier".to_string());
464        }
465        let word = self.to_cardinal(value)?;
466
467        // Big-unit ordinals: drop a leading "un ", strip trailing plural "s",
468        // re-pluralize the -ième suffix when the count was > 1.
469        for &unit in BIG_UNITS.iter() {
470            if word == format!("un {}", unit) || word.ends_with(&format!(" {}s", unit)) {
471                let plural = word.ends_with('s');
472                // Python's str.rstrip("s") strips *all* trailing 's'.
473                let rstripped = word.trim_end_matches('s');
474                let stripped = match rstripped.strip_prefix("un ") {
475                    Some(rest) => rest,
476                    None => rstripped,
477                };
478                return Ok(format!(
479                    "{}ième{}",
480                    stripped,
481                    if plural { "s" } else { "" }
482                ));
483            }
484            if word == unit {
485                return Ok(format!("{}ième", unit));
486            }
487        }
488
489        let mut word = word;
490        let mut matched = false;
491        for &(src, repl) in ORDS.iter() {
492            if word.ends_with(src) {
493                // `src` is ASCII and is a suffix of `word`, so this byte index
494                // is a valid char boundary.
495                let head = word.len() - src.len();
496                word.truncate(head);
497                word.push_str(repl);
498                matched = true;
499                break;
500            }
501        }
502        if !matched {
503            // Python's for/else: only runs when no `src` matched.
504            if word.chars().last() == Some('e') {
505                word.pop();
506            }
507            // Drop the trailing 's' of "cents" / "vingts" before -ième.
508            // (The `endswith("ents")` arm is redundant — anything ending in
509            // "ents" already ends in "ts" — but it is kept for fidelity.)
510            if word.ends_with("ts") || word.ends_with("ents") {
511                word.pop();
512            }
513            word.push_str("ième");
514        }
515        Ok(word)
516    }
517
518    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
519        self.verify_ordinal(value)?;
520        let mut out = value.to_string();
521        out.push_str(if value.is_one() { "er" } else { "me" });
522        Ok(out)
523    }
524
525    /// FR's `to_year` ignores suffix/longval entirely and just delegates.
526    fn to_year(&self, value: &BigInt) -> Result<String> {
527        self.to_cardinal(value)
528    }
529
530    // ---- float/Decimal entries -------------------------------------------
531    //
532    // Python's dispatcher hands floats/Decimals straight to the converter
533    // methods, so `verify_ordinal`'s float/negative checks and `to_year`'s
534    // `int()` truncation become reachable here — unlike on the BigInt hooks
535    // above, where they are dead code. `to_cardinal` needs no override: FR
536    // inherits base's `assert int(value) == value` routing, which is exactly
537    // the trait default (whole -> int path).
538
539    /// `Num2Word_FR.to_ordinal(float/Decimal)`: `verify_ordinal`, then the
540    /// integer path. Whole values ordinalise (5.0 -> "cinquième", 1.0 ->
541    /// "premier" via the `value == 1` special case, Decimal("1E+2") ->
542    /// "centième", 1e+16 -> the plural quirk "dix billiardièmes");
543    /// fractional or negative values raise TypeError.
544    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
545        let i = self.verify_ordinal_float(value)?;
546        self.to_ordinal(&i)
547    }
548
549    /// `Num2Word_FR.to_ordinal_num(float/Decimal)`:
550    /// `str(value) + ("er" if value == 1 else "me")`. The numeric half is
551    /// Python's `str(value)` (`repr_str`), so 1.0 yields "1.0er",
552    /// Decimal("5.00") "5.00me", -0.0 "-0.0me" and Decimal("1E+20")
553    /// "1E+20me". No overflow is possible — Python never words the value.
554    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
555        let i = self.verify_ordinal_float(value)?;
556        Ok(format!(
557            "{}{}",
558            repr_str,
559            if i.is_one() { "er" } else { "me" }
560        ))
561    }
562
563    /// `Num2Word_FR.to_year(float/Decimal)`: `self.to_cardinal(int(val))` —
564    /// `int()` truncates toward zero, so 2.5 -> "deux", -1.5 -> "moins un",
565    /// 0.5 -> "zéro". No non-integer guard (contrast EN's TypeError):
566    /// fractional values are silently truncated, floats and Decimals alike.
567    /// NaN/±inf floats reproduce `int()`'s ValueError/OverflowError, though
568    /// the dispatcher keeps those on the Python side.
569    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
570        let i = match value {
571            FloatValue::Float { value: f, .. } => {
572                if f.is_nan() {
573                    return Err(N2WError::Value(
574                        "cannot convert float NaN to integer".into(),
575                    ));
576                }
577                if f.is_infinite() {
578                    return Err(N2WError::Overflow(
579                        "cannot convert float infinity to integer".into(),
580                    ));
581                }
582                // Exact: every whole f64 is exactly representable.
583                BigInt::from_f64(f.trunc()).ok_or_else(|| {
584                    N2WError::Value("cannot convert float NaN to integer".into())
585                })?
586            }
587            // int(Decimal) truncates toward zero.
588            FloatValue::Decimal { value: d, .. } => d.with_scale(0).as_bigint_and_exponent().0,
589        };
590        self.to_cardinal(&i)
591    }
592
593    // ---- fractions --------------------------------------------------------
594
595    /// Port of `Num2Word_FR.to_fraction` (issue #584): idiomatic
596    /// demi/tiers/quart for denominators 2/3/4 — "tiers" is invariant in the
597    /// plural ("1000000/3" -> "un million tiers"), demi/quart take -s. Every
598    /// other denominator is ordinal-as-noun, appending "s" only when the
599    /// numerator isn't 1 *and* the ordinal doesn't already end in 's' (big
600    /// units like "dix millionièmes" arrive pre-pluralised from
601    /// `to_ordinal`). `d == 1` (exactly 1, not -1) or `n == 0`
602    /// short-circuits to the signed cardinal before any sign normalisation,
603    /// and a zero denominator raises ZeroDivisionError first ("0/0" raises).
604    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
605        if denominator.is_zero() {
606            return Err(N2WError::ZeroDivision(
607                "denominator must not be zero".into(),
608            ));
609        }
610        if denominator.is_one() || numerator.is_zero() {
611            return self.to_cardinal(numerator);
612        }
613        // is_negative = (numerator < 0) ^ (denominator < 0)
614        let is_negative = numerator.is_negative() ^ denominator.is_negative();
615        let abs_n = numerator.abs();
616        let abs_d = denominator.abs();
617        let den_word = if abs_d == BigInt::from(2) {
618            (if abs_n.is_one() { "demi" } else { "demis" }).to_string()
619        } else if abs_d == BigInt::from(3) {
620            // 'tiers' is invariant in plural.
621            "tiers".to_string()
622        } else if abs_d == BigInt::from(4) {
623            (if abs_n.is_one() { "quart" } else { "quarts" }).to_string()
624        } else {
625            let mut w = self.to_ordinal(&abs_d)?;
626            if !abs_n.is_one() && !w.ends_with('s') {
627                w.push('s');
628            }
629            w
630        };
631        let sign = if is_negative {
632            format!("{} ", self.negword().trim())
633        } else {
634            String::new()
635        };
636        Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
637    }
638
639    // ---- currency -------------------------------------------------------
640    //
641    // FR overrides `CURRENCY_FORMS`, `pluralize` and `to_currency`. It does
642    // *not* override `_money_verbose`, `_cents_verbose`, `_cents_terse` or
643    // `to_cheque`, so those keep the trait defaults that mirror
644    // `Num2Word_Base`.
645    //
646    // `currency_precision` is deliberately NOT overridden: FR declares no
647    // `CURRENCY_PRECISION`, and `Num2Word_EN.__init__` *rebinds* rather than
648    // mutates that attribute, so its 3-decimal table never leaks up to
649    // `Num2Word_Base`. FR therefore sees `{}` and every currency divides by
650    // 100 — including JPY/KRW. That is why the corpus wants
651    // "douze yens et trente-quatre sens" instead of a rounded, subunit-less
652    // yen, and why `default_to_currency`'s `divisor == 1` branch is
653    // unreachable from FR.
654
655    fn lang_name(&self) -> &str {
656        "Num2Word_FR"
657    }
658
659    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
660        self.currency_forms.get(code)
661    }
662
663    fn currency_adjective(&self, code: &str) -> Option<&str> {
664        self.currency_adjectives.get(code).copied()
665    }
666
667    /// `Num2Word_FR.pluralize`: `forms[0] if abs(n) <= 1 else forms[1]`.
668    ///
669    /// Overrides `Num2Word_EUR.pluralize` (`forms[0 if n == 1 else 1]`): in
670    /// French **zero takes the singular**, so 1.0 EUR is
671    /// "un euro et zéro centime", not "…zéro centimes". The `abs()` also means
672    /// -1 is singular, though the currency path always passes a magnitude.
673    ///
674    /// Python indexes `forms[1]` directly, so a one-form entry with abs(n) > 1
675    /// would raise IndexError. Every FR entry has two forms, making that
676    /// unreachable — it is mapped to `Index` rather than panicking so the
677    /// exception type survives if the table ever changes.
678    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
679        let form = if n.abs() <= BigInt::one() { 0 } else { 1 };
680        forms
681            .get(form)
682            .cloned()
683            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
684    }
685
686    /// `Num2Word_FR.to_currency`.
687    ///
688    /// FR intercepts the integer case with its own body and delegates
689    /// everything else to `Num2Word_Base.to_currency` via `super()`. The int
690    /// branch is not a reimplementation of Base's — it differs in three ways
691    /// that are all observable, and all reproduced here:
692    ///
693    /// 1. **An unknown currency does not raise.** Base turns the `KeyError`
694    ///    into `NotImplementedError`; FR catches it and returns a bare
695    ///    `to_cardinal(val)`. So `to_currency(0, "KWD")` is "zéro" — no unit,
696    ///    no error — while `to_currency(0.5, "KWD")` *does* raise, because
697    ///    that one reaches Base. The corpus pins both.
698    /// 2. **`adjective` is ignored**, since FR's branch never consults
699    ///    `CURRENCY_ADJECTIVES`. `to_currency(2, "USD", adjective=True)` is
700    ///    "deux dollars", not "deux US dollars" — the adjective only survives
701    ///    on the float path.
702    /// 3. **The plural rule is inlined, not `self.pluralize`.** It agrees with
703    ///    FR's `pluralize` on the 0/1-singular rule but degrades safely
704    ///    (`cr1[1] if len(cr1) > 1 else cr1[0]`) where `pluralize` would
705    ///    IndexError.
706    ///
707    /// Note the fallback returns `to_cardinal(val)` on the **signed** value,
708    /// not `abs_val`, so a negative int keeps its "moins".
709    fn to_currency(
710        &self,
711        val: &CurrencyValue,
712        currency: &str,
713        cents: bool,
714        separator: Option<&str>,
715        adjective: bool,
716    ) -> Result<String> {
717        // Python: `if isinstance(val, int)` — true ints only. A float that
718        // happens to be whole (1.0) is *not* one and still renders cents.
719        if let CurrencyValue::Int(v) = val {
720            let forms = match self.currency_forms.get(currency) {
721                Some(f) => f,
722                // except KeyError: return self.to_cardinal(val)
723                None => return self.to_cardinal(v),
724            };
725
726            let minus_str = if v.is_negative() {
727                format!("{} ", self.negword().trim())
728            } else {
729                String::new()
730            };
731            let abs_val = v.abs();
732            let money_str = self.to_cardinal(&abs_val)?;
733
734            let currency_str = if abs_val <= BigInt::one() {
735                forms.unit.first()
736            } else {
737                forms.unit.get(1).or_else(|| forms.unit.first())
738            }
739            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
740
741            return Ok(format!("{}{} {}", minus_str, money_str, currency_str));
742        }
743
744        // Floats/Decimals: super(Num2Word_FR, self).to_currency(...).
745        crate::currency::default_to_currency(
746            self,
747            val,
748            currency,
749            cents,
750            separator.unwrap_or(self.default_separator()),
751            adjective,
752        )
753    }
754}