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}