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