Skip to main content

num2words2_core/
lang_fr_ch.rs

1//! Port of `lang_FR_CH.py` (Swiss French).
2//!
3//! Shape: **engine**. `Num2Word_FR_CH` subclasses `Num2Word_FR` →
4//! `Num2Word_EUR` → `Num2Word_Base`. It supplies `high_numwords` (from EUR's
5//! `setup`), `mid_numwords` (its own), and `low_numwords` (from FR's `setup`),
6//! and overrides `merge`. `Num2Word_Base.to_cardinal` therefore drives
7//! `splitnum`/`clean`, so the default `to_cardinal` in `base.rs` runs here and
8//! this file supplies `cards`/`maxval`/`merge` plus the FR-inherited
9//! `to_ordinal`/`to_ordinal_num`/`to_year`/`to_fraction` and their
10//! float/Decimal entry hooks (all defined on `Num2Word_FR`).
11//!
12//! # Inheritance chain, resolved
13//!
14//! `Num2Word_FR_CH.setup` calls `Num2Word_FR.setup(self)` **first** and then
15//! reassigns `self.mid_numwords` outright. Since `Num2Word_Base.__init__`
16//! calls `setup()` before `set_numwords()`, FR's mid table (which contains
17//! `(80, "quatre-vingts")` and no 70/90 entries) is **never** inserted into
18//! `cards` — only the Swiss list below survives. Everything else FR's `setup`
19//! assigns (negword, low_numwords, ords, exclude_title) is inherited as-is.
20//!
21//! Inherited unchanged from `Num2Word_FR` (ported verbatim below):
22//!   * `to_ordinal`     — big-unit handling, `ords` table, `-ième` suffixing
23//!   * `to_ordinal_num` — `str(value) + ("er" if value == 1 else "me")`
24//!   * `to_year`        — `return self.to_cardinal(int(val))`, nothing more.
25//!     No century splitting, no BC/AD suffix: `to_year == to_cardinal`.
26//!   * `to_fraction`    — idiomatic demi/tiers/quart, ordinal-as-noun otherwise
27//!
28//! Inherited from `Num2Word_EUR`: `set_high_numwords` (long scale — GIGA
29//! "illiard" at 10^n and MEGA "illion" at 10^(n-3), stepping -6) and
30//! `gen_high_numwords`. `high_numwords` has 100 entries → `cap = 603`, the
31//! highest card is 10^603 ("centilliard") and `MAXVAL = 1000 * 10^603 =
32//! 10^606`. Values are unbounded BigInt; nothing here may be narrowed.
33//!
34//! `is_title` stays `false` (FR only populates `exclude_title`, never sets
35//! `is_title`), so `title()` is a pass-through. `exclude_title` is carried
36//! anyway to mirror the Python object.
37//!
38//! # What makes this Swiss
39//!
40//! Two deviations from `lang_FR`, both in scope:
41//!
42//! 1. **Vigesimal tens are gone.** `mid_numwords` supplies `nonante` (90),
43//!    `huitante` (80) and `septante` (70) as first-class cards, so 70/80/90
44//!    never decompose into `soixante-dix` / `quatre-vingts` / `quatre-vingt-dix`.
45//! 2. **`merge` joins "et" with spaces, not hyphens**, and drops FR's
46//!    `cnum != 80` guard. FR emits `"%s-et-%s"` → "vingt-et-un"; FR_CH emits
47//!    `"%s et %s"` → "vingt et un". And because the `cnum != 80` guard is
48//!    absent, 81 → "huitante et un" (FR would give "quatre-vingt-un").
49//!
50//! # Faithfully reproduced Python bugs
51//!
52//! This is a port, not a rewrite. Verified against the frozen corpus:
53//!
54//! 1. `to_ordinal(0)` == **"zéroième"**. The `for…else` fallback blindly
55//!    appends "ième" to "zéro"; the trailing char is 'o', not 'e', so nothing
56//!    is stripped. Not a word in any French.
57//! 2. `to_ordinal_num(0)` == **"0me"**, `to_ordinal_num(2)` == "2me". The real
58//!    French abbreviation is "2e"/"2ème", never "2me".
59//! 3. **Big-unit ordinals pluralize the suffix, not the noun.**
60//!    `to_ordinal(10**7)` == "dix millionièmes" (plural "-ièmes" on an
61//!    ordinal), and `to_ordinal(10**10)` == "dix milliardièmes". Meanwhile
62//!    `to_ordinal(10**6)` == "millionième" — the leading "un " is dropped for
63//!    the singular but the count word is kept for the plural. Asymmetric and
64//!    almost certainly not intended, but it is what Python emits.
65//! 4. `to_ordinal(1000001)` == "un million unième" — the big-unit branch does
66//!    not fire (the cardinal has a tail), so the "un " prefix survives here
67//!    while `to_ordinal(1000000)` strips it.
68
69use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
70use crate::currency::{CurrencyForms, CurrencyValue};
71use crate::floatpath::FloatValue;
72use num_bigint::BigInt;
73use num_integer::Integer;
74use num_traits::{FromPrimitive, One, Signed, Zero};
75use std::collections::HashMap;
76
77/// `Num2Word_FR._BIG_UNITS`, in Python's declaration order.
78///
79/// Order is load-bearing: `to_ordinal` returns from the first unit that
80/// matches, and the suffixes overlap ("trilliard" vs "trillion", "milliard"
81/// vs "million"), so a reordering would change output.
82const BIG_UNITS: [&str; 6] = [
83    "trilliard",
84    "trillion",
85    "billiard",
86    "billion",
87    "milliard",
88    "million",
89];
90
91/// `Num2Word_FR.ords`, as an ordered slice.
92///
93/// Python 3.7+ dicts iterate in insertion order and the loop `break`s on the
94/// first `endswith` hit, so a `Vec`/slice preserves the semantics exactly
95/// where a `HashMap` would not.
96const ORDS: [(&str, &str); 2] = [("cinq", "cinquième"), ("neuf", "neuvième")];
97
98/// Port of `Num2Word_EUR.gen_high_numwords`.
99///
100/// Defined locally rather than imported from `lang_en` to keep this file
101/// self-contained — the registry is generated mechanically and no other
102/// language file may be depended on.
103fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
104    let mut out: Vec<String> = Vec::new();
105    // Python: [u + t for t in tens for u in units] — `tens` is the outer loop.
106    for t in tens {
107        for u in units {
108            out.push(format!("{}{}", u, t));
109        }
110    }
111    out.reverse();
112
113    const REPLACEMENTS: &[(&str, &str)] = &[
114        ("novemn", "noven"),
115        ("novemo", "novo"),
116        ("octoo", "octo"),
117        ("quintd", "quind"),
118        ("quintn", "quin"),
119        ("quintq", "quinq"),
120        ("quints", "quins"),
121        ("quintt", "quint"),
122        ("quintv", "quinv"),
123        ("septenn", "septen"),
124        ("septent", "sept"),
125        ("sexn", "sen"),
126        ("sexs", "ses"),
127        ("tresd", "tred"),
128        ("tresn", "tren"),
129        ("tress", "tres"),
130        ("tresv", "trev"),
131        ("unno", "uno"),
132    ];
133    for (k, v) in REPLACEMENTS {
134        out = out.iter().map(|o| o.replace(k, v)).collect();
135    }
136    out.extend(lows.iter().map(|s| s.to_string()));
137    out
138}
139
140fn pow10(n: i64) -> BigInt {
141    BigInt::from(10u8).pow(n as u32)
142}
143
144/// `CURRENCY_FORMS` as `Num2Word_FR_CH` actually sees it at runtime.
145///
146/// # This is FR's table, *not* EUR's mutated one
147///
148/// `PORTING_CURRENCY.md` lists `Num2Word_FR_CH` among the 16 classes that read
149/// the shared `Num2Word_EUR.CURRENCY_FORMS` dict after `Num2Word_EN.__init__`
150/// has rewritten it in place. **That is not true of this class**, and the live
151/// interpreter is the authority:
152///
153/// ```text
154/// $ python3 -c "...; pprint(CONVERTER_CLASSES['fr_CH'].CURRENCY_FORMS)"
155/// {'EUR': (('euro', 'euros'), ('centime', 'centimes')), ...}   # 14 codes
156/// ```
157///
158/// `Num2Word_FR` declares its *own* `CURRENCY_FORMS` in its class body, which
159/// shadows `Num2Word_EUR`'s attribute outright. EN's in-place mutation of the
160/// EUR dict therefore never reaches FR or FR_CH, and none of EN's ~24 extra
161/// codes (CHF, KWD, BHD, NGN, …) are visible here. The corpus confirms it from
162/// the other side: `currency:CHF` and `currency:KWD` fall into the KeyError
163/// paths below rather than resolving to EN's "franc"/"dinar".
164///
165/// So this is a verbatim transcription of `lang_FR.py`'s literal — the one
166/// case where reading the source *is* correct. It coincidentally lands on the
167/// same EUR forms EN would have injected (`("euro", "euros")`), which is why
168/// the EUR rows pass either way; GBP is the discriminator, and the corpus's
169/// "livre"/"livres" proves FR's table is the live one.
170fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
171    const DOLLARS: [&str; 2] = ["dollar", "dollars"];
172    const CENTS: [&str; 2] = ["cent", "cents"];
173    const CENTIMES: [&str; 2] = ["centime", "centimes"];
174
175    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
176
177    // ---- Num2Word_FR.CURRENCY_FORMS (class body), in declaration order ----
178    m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &CENTIMES));
179    m.insert("USD", CurrencyForms::new(&DOLLARS, &CENTS));
180    m.insert("FRF", CurrencyForms::new(&["franc", "francs"], &CENTIMES));
181    m.insert("CAD", CurrencyForms::new(&DOLLARS, &CENTS));
182    m.insert("AUD", CurrencyForms::new(&DOLLARS, &CENTS));
183    m.insert("NZD", CurrencyForms::new(&DOLLARS, &CENTS));
184    m.insert("HKD", CurrencyForms::new(&DOLLARS, &CENTS));
185    m.insert("GBP", CurrencyForms::new(&["livre", "livres"], &["penny", "pence"]));
186    // "fen" pluralizing to "jiaos" is wrong — a jiao is 10 fen, a different
187    // unit entirely — so 0.01 CNY is "un fen" but 0.34 CNY is "trente-quatre
188    // jiaos". Transcribed as-is from lang_FR.py; the corpus depends on it.
189    m.insert("CNY", CurrencyForms::new(&["yuan", "yuans"], &["fen", "jiaos"]));
190    m.insert("JPY", CurrencyForms::new(&["yen", "yens"], &["sen", "sens"]));
191    m.insert("INR", CurrencyForms::new(&["roupie", "roupies"], &["paisa", "paisas"]));
192    m.insert("RUB", CurrencyForms::new(&["rouble", "roubles"], &["kopeck", "kopecks"]));
193    m.insert("KRW", CurrencyForms::new(&["won", "wons"], &["jeon", "jeons"]));
194    m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &["centavo", "centavos"]));
195
196    m
197}
198
199/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged.
200///
201/// Unlike `CURRENCY_FORMS`, FR never declares its own `CURRENCY_ADJECTIVES`,
202/// so this attribute *does* resolve up the MRO to `Num2Word_EUR` — confirmed
203/// against the live class. The values are English ("Australian", "US") because
204/// they are EUR's; no French translation exists anywhere in the chain.
205///
206/// Mostly dead weight in practice: `adjective=True` is only honoured on the
207/// float path (FR's integer branch drops the flag — see `to_currency`), and
208/// 8 of these 16 codes (BYN, EEK, HUF, ISK, NOK, RON, SAR, UZS) are absent
209/// from FR's `CURRENCY_FORMS`, so they raise before the adjective is read.
210fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
211    [
212        ("AUD", "Australian"),
213        ("BYN", "Belarusian"),
214        ("CAD", "Canadian"),
215        ("EEK", "Estonian"),
216        ("USD", "US"),
217        ("RUB", "Russian"),
218        ("NOK", "Norwegian"),
219        ("MXN", "Mexican"),
220        ("RON", "Romanian"),
221        ("INR", "Indian"),
222        ("HUF", "Hungarian"),
223        ("ISK", "íslenskar"),
224        ("UZS", "Uzbekistan"),
225        ("SAR", "Saudi"),
226        ("JPY", "Japanese"),
227        ("KRW", "Korean"),
228    ]
229    .into_iter()
230    .collect()
231}
232
233/// Python `str(float)`/`repr(float)`, used only to build error-message text
234/// (`errmsg_floatord`, `errmsg_negord`). The corpora compare exception
235/// *types*; the text follows the Python format strings. Whole floats keep
236/// their ".0" ("21.0"), |v| >= 1e16 switches to Python's exponent form
237/// ("1e+20"), and -0.0 keeps its sign.
238fn py_float_str(f: f64) -> String {
239    if f.is_nan() {
240        return "nan".to_string();
241    }
242    if f.is_infinite() {
243        return if f.is_sign_positive() {
244            "inf".to_string()
245        } else {
246            "-inf".to_string()
247        };
248    }
249    let a = f.abs();
250    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
251        // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
252        let s = format!("{:e}", f);
253        if let Some((m, e)) = s.split_once('e') {
254            let (sign, digits) = match e.strip_prefix('-') {
255                Some(d) => ("-", d.to_string()),
256                None => ("+", e.to_string()),
257            };
258            let digits = if digits.len() < 2 {
259                format!("0{}", digits)
260            } else {
261                digits
262            };
263            return format!("{}e{}{}", m, sign, digits);
264        }
265        s
266    } else if f.fract() == 0.0 {
267        // repr keeps the trailing ".0" that Rust's `{}` would drop.
268        format!("{:.1}", f)
269    } else {
270        format!("{}", f)
271    }
272}
273
274/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
275fn py_num_str(v: &FloatValue) -> String {
276    match v {
277        FloatValue::Float { value, .. } => py_float_str(*value),
278        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
279    }
280}
281
282pub struct LangFrCh {
283    cards: Cards,
284    maxval: BigInt,
285    exclude_title: Vec<String>,
286    currency_forms: HashMap<&'static str, CurrencyForms>,
287    currency_adjectives: HashMap<&'static str, &'static str>,
288}
289
290impl Default for LangFrCh {
291    fn default() -> Self {
292        Self::new()
293    }
294}
295
296impl LangFrCh {
297    pub fn new() -> Self {
298        // Num2Word_EUR.setup
299        let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
300        let units = [
301            "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
302        ];
303        let tens = [
304            "dec",
305            "vigint",
306            "trigint",
307            "quadragint",
308            "quinquagint",
309            "sexagint",
310            "septuagint",
311            "octogint",
312            "nonagint",
313        ];
314        let mut high = vec!["cent".to_string()];
315        high.extend(gen_high_numwords(&units, &tens, &lows));
316
317        let mut cards = Cards::new();
318
319        // Num2Word_EUR.set_high_numwords: long scale.
320        //   cap = 3 + 6 * len(high)                       (= 603)
321        //   for word, n in zip(high, range(cap, 3, -6)):
322        //       cards[10**n]     = word + GIGA_SUFFIX     ("illiard")
323        //       cards[10**(n-3)] = word + MEGA_SUFFIX     ("illion")
324        // The `n > 3` guard reproduces zip() stopping at the shorter operand.
325        let cap = 3 + 6 * high.len() as i64;
326        let mut n = cap;
327        for word in high.iter() {
328            if n <= 3 {
329                break;
330            }
331            cards.insert(pow10(n), format!("{}illiard", word));
332            cards.insert(pow10(n - 3), format!("{}illion", word));
333            n -= 6;
334        }
335
336        // Num2Word_FR_CH.setup — replaces FR's mid table wholesale.
337        // No "quatre-vingts"; septante/huitante/nonante are real cards.
338        set_mid_numwords(
339            &mut cards,
340            &[
341                (1000, "mille"),
342                (100, "cent"),
343                (90, "nonante"),
344                (80, "huitante"),
345                (70, "septante"),
346                (60, "soixante"),
347                (50, "cinquante"),
348                (40, "quarante"),
349                (30, "trente"),
350            ],
351        );
352
353        // Num2Word_FR.setup — inherited unchanged.
354        set_low_numwords(
355            &mut cards,
356            &[
357                "vingt",
358                "dix-neuf",
359                "dix-huit",
360                "dix-sept",
361                "seize",
362                "quinze",
363                "quatorze",
364                "treize",
365                "douze",
366                "onze",
367                "dix",
368                "neuf",
369                "huit",
370                "sept",
371                "six",
372                "cinq",
373                "quatre",
374                "trois",
375                "deux",
376                "un",
377                "zéro",
378            ],
379        );
380
381        // MAXVAL = 1000 * list(self.cards.keys())[0]; the OrderedDict's first
382        // key is the highest card (10^603), so MAXVAL = 10^606.
383        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
384
385        LangFrCh {
386            cards,
387            maxval,
388            exclude_title: vec!["et".into(), "virgule".into(), "moins".into()],
389            // Built once here, never per call: these are class attributes in
390            // Python, evaluated at import time.
391            currency_forms: build_currency_forms(),
392            currency_adjectives: build_currency_adjectives(),
393        }
394    }
395
396    /// `Num2Word_Base.verify_ordinal`.
397    ///
398    /// The float check cannot fire on integer input; only the negative check
399    /// is reachable, and it raises `TypeError` (matching the corpus's
400    /// `err: "TypeError"` on every negative ordinal row).
401    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
402        if value.is_negative() {
403            return Err(N2WError::Type(format!(
404                "Cannot treat negative num {} as ordinal.",
405                value
406            )));
407        }
408        Ok(())
409    }
410
411    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
412    /// Python's order:
413    ///
414    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
415    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
416    ///
417    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
418    /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
419    /// "zéroième", not an error. A negative fractional value (-1.5) fails
420    /// check 1 first, so it raises the *float* message, as Python does.
421    /// Returns the integral value for the integer-path continuation.
422    /// (NaN/±inf would make `int(value)` raise before either check, but the
423    /// dispatcher keeps those on the Python side; `as_whole_int` maps them to
424    /// the float-message arm, which is unreachable for them in practice.)
425    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
426        match value.as_whole_int() {
427            Some(i) => {
428                if i.is_negative() {
429                    Err(N2WError::Type(format!(
430                        "Cannot treat negative num {} as ordinal.",
431                        py_num_str(value)
432                    )))
433                } else {
434                    Ok(i)
435                }
436            }
437            None => Err(N2WError::Type(format!(
438                "Cannot treat float {} as ordinal.",
439                py_num_str(value)
440            ))),
441        }
442    }
443}
444
445impl Lang for LangFrCh {
446    /// This language's own `to_currency(currency=...)` default,
447    /// read from the live Python signature. Only 44 of 156 use EUR.
448    fn default_currency(&self) -> &str {
449        "EUR"
450    }
451
452    /// This language's own `to_currency(separator=...)` default,
453    /// read from the live Python signature. Base's is ",", but only
454    /// 36 of 149 languages actually use it — most default to " " or a
455    /// conjunction, so inheriting Base's comma silently corrupts them.
456    fn default_separator(&self) -> &str {
457        " et"
458    }
459
460    fn cards(&self) -> &Cards {
461        &self.cards
462    }
463    fn maxval(&self) -> &BigInt {
464        &self.maxval
465    }
466    fn negword(&self) -> &str {
467        "moins "
468    }
469    fn pointword(&self) -> &str {
470        "virgule"
471    }
472    fn exclude_title(&self) -> &[String] {
473        &self.exclude_title
474    }
475
476    /// Port of `Num2Word_FR_CH.merge`.
477    ///
478    /// Differs from `Num2Word_FR.merge` in exactly two places, both marked
479    /// below: the "et" join uses spaces, and the `cnum != 80` guard is gone.
480    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
481        let (ltext, cnum) = l;
482        let (rtext, nnum) = r;
483
484        let mut ctext = ltext.to_string();
485        let mut ntext = rtext.to_string();
486
487        let eighty = BigInt::from(80);
488        let hundred = BigInt::from(100);
489        let thousand = BigInt::from(1000);
490        let million = BigInt::from(1_000_000);
491        let ten = BigInt::from(10);
492
493        if cnum.is_one() {
494            if nnum < &million {
495                return (rtext.to_string(), nnum.clone());
496            }
497            // NB: Python falls through here — when cnum == 1 and
498            // nnum >= 1_000_000 neither branch of the if/else runs, so no
499            // 's' is stripped or added. Hence "un million", not "un millions".
500        } else {
501            // Drop trailing 's' from 'cents' / 'vingts' before a continuation.
502            // `(cnum - 80) % 100` uses Python's floor-mod: (30-80) % 100 == 50
503            // in Python but -50 in Rust's `%`, so mod_floor is required for
504            // parity even though both happen to be non-zero here.
505            let ends_in_80 = (cnum - &eighty).mod_floor(&hundred).is_zero();
506            let round_hundred = (cnum % &hundred).is_zero() && cnum < &thousand;
507            if (ends_in_80 || round_hundred)
508                && nnum < &million
509                && ctext.chars().last() == Some('s')
510            {
511                ctext.pop();
512            }
513            // Pluralize a round multiplier that is not itself 'mille':
514            // 2 * 100 -> "deux cents".
515            if cnum < &thousand
516                && nnum != &thousand
517                && ntext.chars().last() != Some('s')
518                && (nnum % &hundred).is_zero()
519            {
520                ntext.push('s');
521            }
522        }
523
524        if nnum < cnum && cnum < &hundred {
525            if (nnum % &ten).is_one() {
526                // FR_CH: spaces around "et" (FR uses "%s-et-%s"), and no
527                // `cnum != 80` guard — 81 becomes "huitante et un".
528                return (format!("{} et {}", ctext, ntext), cnum + nnum);
529            }
530            return (format!("{}-{}", ctext, ntext), cnum + nnum);
531        }
532        if nnum > cnum {
533            return (format!("{} {}", ctext, ntext), cnum * nnum);
534        }
535        (format!("{} {}", ctext, ntext), cnum + nnum)
536    }
537
538    /// Port of `Num2Word_FR.to_ordinal` (inherited unchanged by FR_CH).
539    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
540        self.verify_ordinal(value)?;
541        if value.is_one() {
542            return Ok("premier".to_string());
543        }
544        let word = self.to_cardinal(value)?;
545
546        // Big-unit ordinals. Checked in _BIG_UNITS order; the first hit wins.
547        for unit in BIG_UNITS {
548            if word == format!("un {}", unit) || word.ends_with(&format!(" {}s", unit)) {
549                let plural = word.ends_with('s');
550                // Python's str.rstrip("s") strips *every* trailing 's'.
551                let trimmed = word.trim_end_matches('s');
552                let stripped = trimmed.strip_prefix("un ").unwrap_or(trimmed);
553                return Ok(format!(
554                    "{}ième{}",
555                    stripped,
556                    if plural { "s" } else { "" }
557                ));
558            }
559            if word == unit {
560                return Ok(format!("{}ième", unit));
561            }
562        }
563
564        // Python's for/else: the else arm runs only if no `break` fired.
565        // `replaced` carries the hit out of the loop so that the borrow taken
566        // by `strip_suffix` ends before `w` is reassigned.
567        let mut w = word;
568        let mut replaced: Option<String> = None;
569        for (src, repl) in ORDS {
570            if let Some(head) = w.strip_suffix(src) {
571                replaced = Some(format!("{}{}", head, repl));
572                break;
573            }
574        }
575        if let Some(hit) = replaced {
576            w = hit;
577        } else {
578            // `word[-1]` / `word[:-1]` are character ops in Python; String::pop
579            // is UTF-8 aware, so "zéro" is never split mid-codepoint.
580            if w.chars().last() == Some('e') {
581                w.pop();
582            }
583            // 200 -> "deux centième", not "deux centsième". (`endswith("ents")`
584            // is subsumed by `endswith("ts")`; kept verbatim.)
585            if w.ends_with("ts") || w.ends_with("ents") {
586                w.pop();
587            }
588            w.push_str("ième");
589        }
590        Ok(w)
591    }
592
593    /// Port of `Num2Word_FR.to_ordinal_num`: `str(value) + ("er" | "me")`.
594    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
595        self.verify_ordinal(value)?;
596        let suffix = if value.is_one() { "er" } else { "me" };
597        Ok(format!("{}{}", value, suffix))
598    }
599
600    /// Port of `Num2Word_FR.to_year`: `return self.to_cardinal(int(val))`.
601    ///
602    /// No century splitting and no era suffix — negatives simply pick up
603    /// `negword` from `to_cardinal` ("moins cinq cents" for -500).
604    fn to_year(&self, value: &BigInt) -> Result<String> {
605        self.to_cardinal(value)
606    }
607
608    // ---- float/Decimal entries -------------------------------------------
609    //
610    // Python's dispatcher hands floats/Decimals straight to the converter
611    // methods, so `verify_ordinal`'s float/negative checks and `to_year`'s
612    // `int()` truncation become reachable here — unlike on the BigInt hooks
613    // above, where they are dead code. `to_cardinal` needs no override: the
614    // FR_CH → FR → EUR → Base chain inherits base's `assert int(value) ==
615    // value` routing, which is exactly the trait default (whole -> int path).
616
617    /// `Num2Word_FR.to_ordinal(float/Decimal)` (inherited by FR_CH):
618    /// `verify_ordinal`, then the integer path. Whole values ordinalise
619    /// (5.0 -> "cinquième", 1.0 -> "premier" via the `value == 1` special
620    /// case, 21.0 -> the Swiss "vingt et unième", Decimal("1E+2") ->
621    /// "centième", 1e+16 -> the plural quirk "dix billiardièmes");
622    /// fractional or negative values raise TypeError.
623    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
624        let i = self.verify_ordinal_float(value)?;
625        self.to_ordinal(&i)
626    }
627
628    /// `Num2Word_FR.to_ordinal_num(float/Decimal)`:
629    /// `str(value) + ("er" if value == 1 else "me")`. The numeric half is
630    /// Python's `str(value)` (`repr_str`), so 1.0 yields "1.0er",
631    /// Decimal("5.00") "5.00me", -0.0 "-0.0me" and Decimal("1E+20")
632    /// "1E+20me". No overflow is possible — Python never words the value.
633    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
634        let i = self.verify_ordinal_float(value)?;
635        Ok(format!(
636            "{}{}",
637            repr_str,
638            if i.is_one() { "er" } else { "me" }
639        ))
640    }
641
642    /// `Num2Word_FR.to_year(float/Decimal)`: `self.to_cardinal(int(val))` —
643    /// `int()` truncates toward zero, so 2.5 -> "deux", -1.5 -> "moins un",
644    /// 0.5 -> "zéro". No non-integer guard (contrast EN's TypeError):
645    /// fractional values are silently truncated, floats and Decimals alike.
646    /// NaN/±inf floats reproduce `int()`'s ValueError/OverflowError, though
647    /// the dispatcher keeps those on the Python side.
648    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
649        let i = match value {
650            FloatValue::Float { value: f, .. } => {
651                if f.is_nan() {
652                    return Err(N2WError::Value(
653                        "cannot convert float NaN to integer".into(),
654                    ));
655                }
656                if f.is_infinite() {
657                    return Err(N2WError::Overflow(
658                        "cannot convert float infinity to integer".into(),
659                    ));
660                }
661                // Exact: every whole f64 is exactly representable.
662                BigInt::from_f64(f.trunc()).ok_or_else(|| {
663                    N2WError::Value("cannot convert float NaN to integer".into())
664                })?
665            }
666            // int(Decimal) truncates toward zero.
667            FloatValue::Decimal { value: d, .. } => d.with_scale(0).as_bigint_and_exponent().0,
668        };
669        self.to_cardinal(&i)
670    }
671
672    // ---- fractions --------------------------------------------------------
673
674    /// Port of `Num2Word_FR.to_fraction` (issue #584), inherited unchanged by
675    /// `Num2Word_FR_CH`: idiomatic demi/tiers/quart for denominators 2/3/4 —
676    /// "tiers" is invariant in the plural ("1000000/3" -> "un million
677    /// tiers"), demi/quart take -s. Every other denominator is
678    /// ordinal-as-noun, appending "s" only when the numerator isn't 1 *and*
679    /// the ordinal doesn't already end in 's' (big units like "dix
680    /// millionièmes" arrive pre-pluralised from `to_ordinal`). `d == 1`
681    /// (exactly 1, not -1) or `n == 0` short-circuits to the signed cardinal
682    /// before any sign normalisation, and a zero denominator raises
683    /// ZeroDivisionError first ("0/0" raises).
684    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
685        if denominator.is_zero() {
686            return Err(N2WError::ZeroDivision(
687                "denominator must not be zero".into(),
688            ));
689        }
690        if denominator.is_one() || numerator.is_zero() {
691            return self.to_cardinal(numerator);
692        }
693        // is_negative = (numerator < 0) ^ (denominator < 0)
694        let is_negative = numerator.is_negative() ^ denominator.is_negative();
695        let abs_n = numerator.abs();
696        let abs_d = denominator.abs();
697        let den_word = if abs_d == BigInt::from(2) {
698            (if abs_n.is_one() { "demi" } else { "demis" }).to_string()
699        } else if abs_d == BigInt::from(3) {
700            // 'tiers' is invariant in plural.
701            "tiers".to_string()
702        } else if abs_d == BigInt::from(4) {
703            (if abs_n.is_one() { "quart" } else { "quarts" }).to_string()
704        } else {
705            let mut w = self.to_ordinal(&abs_d)?;
706            if !abs_n.is_one() && !w.ends_with('s') {
707                w.push('s');
708            }
709            w
710        };
711        let sign = if is_negative {
712            format!("{} ", self.negword().trim())
713        } else {
714            String::new()
715        };
716        Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
717    }
718
719    // ---- currency -----------------------------------------------------
720    //
721    // FR_CH adds nothing of its own here; every hook below resolves up the
722    // MRO to `Num2Word_FR` (forms, pluralize, to_currency) or `Num2Word_EUR`
723    // (adjectives). Only `lang_name` is genuinely FR_CH's, and only because
724    // Python reads `self.__class__.__name__` off the instance.
725
726    fn lang_name(&self) -> &str {
727        // `self.__class__.__name__` — the *runtime* class, so the
728        // NotImplementedError raised by FR's inherited code still names
729        // FR_CH: 'Currency code "CHF" not implemented for "Num2Word_FR_CH"'.
730        "Num2Word_FR_CH"
731    }
732
733    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
734        self.currency_forms.get(code)
735    }
736
737    fn currency_adjective(&self, code: &str) -> Option<&str> {
738        self.currency_adjectives.get(code).copied()
739    }
740
741    // `currency_precision` is deliberately left at the trait default of 100.
742    // `CURRENCY_PRECISION` is `{}` on the live FR_CH class: base declares it
743    // empty, FR/FR_CH never touch it, and EN's `self.CURRENCY_PRECISION = {…}`
744    // *rebinds* rather than mutates, so its 3-decimal entries do not leak.
745    // Consequences, both corpus-confirmed:
746    //   * JPY keeps divisor 100, so 12.34 JPY is "douze yens et trente-quatre
747    //     sens" — the zero-decimal branch of `default_to_currency` is dead here.
748    //   * KWD/BHD would be divisor 100 too, but they are absent from FR's
749    //     forms and raise before precision is ever consulted.
750
751    /// Port of `Num2Word_FR.pluralize`, which *overrides* `Num2Word_EUR`'s.
752    ///
753    /// EUR's rule is `0 if n == 1 else 1`; FR's is `forms[0] if abs(n) <= 1`.
754    /// The difference is entirely about **zero**, and it is load-bearing: the
755    /// corpus row `1.0 EUR -> "un euro et zéro centime"` needs `pluralize(0,
756    /// ("centime", "centimes"))` to pick the singular. EUR's rule would emit
757    /// "zéro centimes". Ports savoirfairelinux/num2words#532 (issue #70).
758    ///
759    /// `abs(n)` also makes -1 singular, though `to_currency` only ever passes
760    /// non-negative counts, so that arm is unreachable from the corpus.
761    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
762        let form = if n.abs() <= BigInt::one() { 0 } else { 1 };
763        // Python indexes `forms[1]` unguarded; a 1-form tuple would raise
764        // IndexError. Every FR entry has exactly 2 forms, so this is
765        // unreachable — kept faithful rather than defaulted.
766        forms
767            .get(form)
768            .cloned()
769            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
770    }
771
772    /// Port of `Num2Word_FR.to_currency`.
773    ///
774    /// Two paths, split on `isinstance(val, int)` — *not* on whether the
775    /// number is whole. `1` is "un euro"; `1.0` is "un euro et zéro centime".
776    ///
777    /// The integer path is FR's own hand-rolled branch and diverges from
778    /// `Num2Word_Base.to_currency` in three observable ways, all reproduced:
779    ///
780    /// 1. **An unknown currency does not raise.** Base re-raises the KeyError
781    ///    as NotImplementedError; FR catches it and returns a bare
782    ///    `self.to_cardinal(val)` with no currency word at all. Hence the
783    ///    corpus's `currency:CHF, 1000000 -> "un million"` — a currency
784    ///    conversion that silently forgets the currency. The float path has no
785    ///    such fallback, so the *same* code raises there: `currency:CHF, 0.5
786    ///    -> NotImplementedError`. Same input, same code, opposite outcomes.
787    /// 2. **`adjective` is dropped on the floor.** The integer branch never
788    ///    consults `CURRENCY_ADJECTIVES`, so `to_currency(2, "USD",
789    ///    adjective=True)` is "deux dollars" while the float `2.0` is "deux US
790    ///    dollars, ...". Not exercised by the corpus (every row is
791    ///    `adjective=False`), but it is what Python does.
792    /// 3. **`pluralize` is inlined, not called.** The branch open-codes
793    ///    `cr1[0] if abs_val <= 1 else (cr1[1] if len(cr1) > 1 else cr1[0])`.
794    ///    That is FR's `pluralize` rule plus a `len` guard the method lacks,
795    ///    so the two agree on every FR entry. Kept as written.
796    fn to_currency(
797        &self,
798        val: &CurrencyValue,
799        currency: &str,
800        cents: bool,
801        separator: Option<&str>,
802        adjective: bool,
803    ) -> Result<String> {
804        // `None` means the caller omitted `separator=`; resolve it to FR's own
805        // signature default (" et") before the ported body runs.
806        let separator = separator.unwrap_or(self.default_separator());
807
808        // ---- integer path: never shows cents ----
809        if let CurrencyValue::Int(v) = val {
810            let forms = match self.currency_forms.get(currency) {
811                Some(f) => f,
812                // `except KeyError: return self.to_cardinal(val)` — note it
813                // passes the *signed* val, so the sign is handled by
814                // to_cardinal's own negword rather than minus_str below.
815                None => return self.to_cardinal(v),
816            };
817
818            // See divergence (2) above: the flag is accepted and ignored.
819            let _ = adjective;
820
821            let minus_str = if v.is_negative() {
822                format!("{} ", self.negword().trim())
823            } else {
824                String::new()
825            };
826            let abs_val = v.abs();
827            let money_str = self.to_cardinal(&abs_val)?;
828
829            let cr1 = &forms.unit;
830            let currency_str = if abs_val <= BigInt::one() {
831                cr1.first()
832            } else if cr1.len() > 1 {
833                cr1.get(1)
834            } else {
835                cr1.first()
836            }
837            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
838
839            return Ok(format!("{}{} {}", minus_str, money_str, currency_str));
840        }
841
842        // ---- float path: `super().to_currency(...)` ----
843        //
844        // `Num2Word_EUR` defines no `to_currency`, so `super(Num2Word_FR,
845        // self)` resolves straight through to `Num2Word_Base.to_currency` —
846        // i.e. `default_to_currency`. FR forwards every kwarg unchanged.
847        crate::currency::default_to_currency(self, val, currency, cents, separator, adjective)
848    }
849
850    // `to_cheque` is not overridden anywhere in the chain, so
851    // `default_to_cheque` runs: 1234.56 EUR -> "MILLE DEUX CENT TRENTE-QUATRE
852    // AND 56/100 EUROS". Note it takes `cr1[-1]` unconditionally, so the
853    // English "AND" and the plural unit appear even in French, and an unknown
854    // code (CHF/KWD/BHD) raises NotImplementedError with no int fallback.
855}