Skip to main content

num2words2_core/
lang_ff.rs

1//! Port of `lang_FF.py` (Fulah — Pulaar/Fulfulde).
2//!
3//! Shape: **self-contained**. `Num2Word_FF` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so
5//! `Num2Word_Base.__init__` never builds `self.cards` and never sets
6//! `MAXVAL`. `to_cardinal` is overridden outright and drives the recursive
7//! `_int_to_word` helper. Consequently `cards`/`maxval`/`merge` stay at their
8//! trait defaults here, and there is **no overflow check** — see the "10^9
9//! cliff" note below for what happens instead.
10//!
11//! Inherited from `Num2Word_Base`, but every in-scope entry point is
12//! overridden by `Num2Word_FF`, so nothing from the base engine is reachable:
13//!   * `to_cardinal`     — overridden (below)
14//!   * `to_ordinal`      — overridden: `to_cardinal(n) + "ɓal"`
15//!   * `to_ordinal_num`  — overridden: `str(n) + "ɓal"`
16//!   * `to_year`         — overridden: `to_cardinal(val)`, ignoring `longval`
17//!     and the base class's whole AD/BC + two-halves-of-the-year machinery.
18//!
19//! Notably `Num2Word_FF.to_cardinal` never calls `self.title()`, so the
20//! `is_title`/`exclude_title` pathway in the base class is dead code for this
21//! language. `setup()` does populate `exclude_title = ["e", "feccere",
22//! "less"]`, and it is reproduced below for fidelity, but `is_title` is left
23//! at the `False` that `__init__` assigns, so `title()` would be an identity
24//! function even if something did reach it.
25//!
26//! # Faithfully reproduced Python behaviour
27//!
28//! This is a port, not a rewrite. The following all look wrong but are
29//! exactly what Python emits, and are confirmed by the frozen corpus:
30//!
31//! 1. **The 10^9 cliff.** `_int_to_word` handles 0, <10, <100, <1000, <10^6
32//!    and <10^9, then falls off the end with a bare `return str(number)`.
33//!    Every value >= 1_000_000_000 is therefore returned as **bare digits**,
34//!    not words. Corpus: `cardinal(1000000000)` == `"1000000000"`,
35//!    `cardinal(1234567890)` == `"1234567890"`, `cardinal(10**12)` ==
36//!    `"1000000000000"`. This is not an error path — no exception, no
37//!    `OverflowError` — so the language never raises for integer input and
38//!    accepts arbitrarily large `BigInt` values. It also means
39//!    `to_ordinal(10**9)` == `"1000000000ɓal"`, which is byte-for-byte
40//!    identical to `to_ordinal_num(10**9)`. Both are in the corpus.
41//! 2. **`negword` is the English word "less "**, not a Fulah word, and it is
42//!    concatenated *without* a separating join — the trailing space in the
43//!    literal is what separates it. Hence `cardinal(-1)` == `"less go'o"`.
44//!    The `.strip()` Python applies afterwards is a no-op for every integer
45//!    input (nothing produces leading/trailing whitespace), but it is
46//!    reproduced anyway. Combined with bug 1, `cardinal(-10**9)` ==
47//!    `"less 1000000000"`.
48//! 3. **The millions branch has no `m > 1` guard**, unlike the hundreds and
49//!    thousands branches. `100` → `"teemerre"` and `1000` → `"ujunere"` (bare,
50//!    no "go'o"), but `1000000` → `"go'o miliyon"` (with "go'o"). Corpus
51//!    confirms all three. The asymmetry is deliberate-looking enough that it
52//!    may be intended, but it is preserved verbatim regardless.
53//! 4. **Multiplier words precede their multiplicand**: `200` is
54//!    `"teemerre ɗiɗi"` (literally "hundred two") and `10000` is
55//!    `"ujunere sappo"` ("thousand ten") — the count follows the unit. But
56//!    the millions branch (bug 3) puts the count *first*: `"go'o miliyon"`.
57//!    Both orders coexist in one output: `cardinal(123456789)` begins
58//!    `"teemerre e noogaas e tati miliyon e ujunere teemerre nayi ..."`.
59//!
60//! # Float / Decimal routing
61//!
62//! `to_cardinal` turns on `str(number)`, so a float/Decimal is routed by the
63//! *string* form, never by base's `int(value) == value` whole-value test:
64//!
65//! * Every repr with a visible `"."` takes the `pointword` branch — **whole
66//!   values included**: `5.0` → `"jowi feccere sufri"`, `Decimal("5.00")` →
67//!   `"jowi feccere sufri sufri"`. [`Lang::cardinal_float_entry`] is therefore
68//!   overridden to bypass the base whole→int routing entirely.
69//! * An exponent-form repr has no `"."` and lands in `int(n)`, which raises
70//!   `ValueError: invalid literal for int() with base 10: '1e+16'`
71//!   (`str(1e16)` == `"1e+16"`; likewise `str(Decimal("1E+2"))` == `"1E+2"`).
72//!   Reproduced via [`python_float_repr`] and
73//!   [`crate::strnum::python_decimal_str`].
74//! * `-0.0` keeps its negword because the sign lives in the *string*
75//!   (`"-0.0"` startswith `"-"`): `"less sufri feccere sufri"`. The binding
76//!   hands `Decimal("-0.0")` over as a signed-zero float for the same reason
77//!   (a `BigDecimal` cannot carry the sign of zero), so both spellings agree.
78//! * A whole `Decimal` with **no** fractional digits has no point in its str
79//!   (`str(Decimal("5"))` == `"5"`) and takes the integer branch — which is
80//!   why `Decimal("5")` is `"jowi"` while `5.0` is `"jowi feccere sufri"`.
81//! * `to_ordinal`/`to_year` glue onto `to_cardinal`, so floats follow the
82//!   same routing (`to_ordinal(5.0)` == `"jowi feccere sufriɓal"`,
83//!   `to_ordinal(1e16)` raises), and `to_ordinal_num` suffixes the raw repr
84//!   (`"5.0ɓal"`, `"1e+16ɓal"` — *not* an error there).
85//! * `num2words("Infinity")` parses to `Decimal("Infinity")` on the Python
86//!   side and only fails *inside* FF's `int("Infinity")` — **ValueError**,
87//!   not the base path's OverflowError. The Rust dispatcher hard-codes
88//!   `ParsedNumber::Inf` → OverflowError before any language hook runs, so
89//!   [`Lang::str_to_number`] is overridden to surface the ValueError at
90//!   parse time instead. Known divergence: `to_ordinal_num("Infinity")`
91//!   would be `"Infinityɓal"` in Python and ValueError here — no corpus row.
92//!
93//! # Currency
94//!
95//! `Num2Word_FF` declares its **own** `CURRENCY_FORMS` class attribute and
96//! descends from `Num2Word_Base`, not `Num2Word_EUR` — so it is untouched by
97//! the `Num2Word_EN.__init__` mutation that rewrites `Num2Word_EUR`'s shared
98//! class dict in place. Confirmed against the live interpreter *after* a full
99//! `import num2words2` (which instantiates `Num2Word_EN()` and fires that
100//! mutation):
101//!
102//! ```text
103//! {'EUR': (('yero', 'yero'), ('santiim', 'santiim')),
104//!  'USD': (('dolaar', 'dolaar'), ('santiim', 'santiim')),
105//!  'XOF': (('seefaa', 'seefaa'), ('santiim', 'santiim'))}
106//! ```
107//!
108//! Both forms of every entry are identical, so the singular/plural choice is
109//! unobservable in the output — but the arity (2) is load-bearing, because
110//! `to_currency` indexes `cr1[1]` directly rather than going through
111//! `pluralize`.
112//!
113//! `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both inherited from
114//! `Num2Word_Base` and are `{}` at runtime, so `currency_adjective` stays
115//! `None` and `currency_precision` stays 100 for **every** code — JPY, KWD and
116//! BHD included. (`Num2Word_FF.CURRENCY_PRECISION` *is* the Base dict object,
117//! so an in-place mutation elsewhere would leak into it; the live check says
118//! nothing performs one — `Num2Word_EN.__init__` *rebinds* rather than mutates,
119//! creating an instance attribute that never reaches the class dict.) FF's
120//! `to_currency` never consults precision at all, so base's 0-decimal and
121//! 3-decimal branches are unreachable here. The corpus pins this: `currency:JPY
122//! 12.34` still shows santiim, and `currency:KWD 12.34` slices two subunit
123//! digits, not three.
124//!
125//! `to_currency` is overridden **wholesale**: no `parse_currency_parts`, no
126//! `ROUND_HALF_UP` quantize, no per-currency divisor, no `pluralize`, and — the
127//! big one — **no `NotImplementedError`**. It resolves the code with
128//! `.get(currency, list(self.CURRENCY_FORMS.values())[0])`, so an unknown code
129//! silently borrows **XOF**'s forms (the first entry of the class-body literal).
130//!
131//! `to_cheque` is **not** overridden, so `currency::default_to_cheque` (the port
132//! of `Num2Word_Base.to_cheque`) serves it. That one subscripts strictly —
133//! `self.CURRENCY_FORMS[currency]` — so it *does* raise NotImplementedError.
134//! Both halves of the asymmetry are corpus-pinned: `currency:GBP 0` →
135//! `"sufri seefaa"`, `cheque:GBP 1234.56` → NotImplementedError.
136//!
137//! `_money_verbose`, `_cents_verbose` and `_cents_terse` are inherited
138//! unchanged. Only `_money_verbose` is reachable (from `to_cheque`), and it
139//! delegates to FF's `to_cardinal` — which is why a cheque at or above 10^9
140//! would print bare digits (bug 1) in the words position.
141//!
142//! # Further faithfully reproduced Python behaviour (currency)
143//!
144//! 5. **`to_currency` splits `str(val)`; it does no arithmetic.** `right` is
145//!    `int(parts[1][:2].ljust(2, "0"))` — the first two *characters* of the
146//!    fraction, right-padded to two. So `0.5` → `"5"` → `"50"` → 50 santiim,
147//!    while `0.05` → `"05"` → 5. It truncates rather than rounds: `12.349` →
148//!    34, `0.005` → 0, `1.999` → 99. Corpus: `currency:EUR 0.5` →
149//!    `"sufri yero capanɗe jowi santiim"`.
150//! 6. **`1.0` prints no cents.** Its fraction `"0"` pads to `"00"`, so
151//!    `right == 0` and `if cents and right:` drops the clause entirely. A float
152//!    that happens to be whole therefore renders exactly like an int — the one
153//!    place FF agrees with base's `isinstance(val, int)` branch, reached by a
154//!    completely different route. Corpus: `currency:EUR 1.0` → `"go'o yero"`.
155//! 7. **`cents=False` drops the subunit segment outright**, rather than
156//!    emitting zero-padded digits the way base's `_cents_terse` would. The flag
157//!    is only ever read as `cents and right`.
158//! 8. **`adjective` is declared and never read.** `CURRENCY_ADJECTIVES` is
159//!    empty anyway, so even base's behaviour would be a no-op.
160//! 9. **The separator is concatenated raw** (`result += separator + ...`), with
161//!    no space of its own. FF's own signature default is `" "` — *not* Base's
162//!    `","` — and that is what supplies the gap; an explicit `separator=","`
163//!    yields `"...yero,capanɗe tati e nayi santiim"`.
164//! 10. **`negword` is prepended un-stripped** here: `self.negword + result`.
165//!    The trailing space in `"less "` supplies the gap and the trailing
166//!    `.strip()` is a no-op — reproduced rather than assumed away.
167//!
168//! # Error variants
169//!
170//! For integer input this language has **no reachable exception path**: every
171//! list index is guarded (`ONES[0]`/`TENS[0]` are the empty string, but the
172//! `number == 0` early return means they are never emitted), the 10^9 fallback
173//! swallows what would otherwise be an overflow, and no dict lookup occurs. All
174//! four in-scope modes return `Ok` for every `BigInt`.
175//!
176//! The currency surface adds exactly two:
177//!
178//! * `NotImplemented` — `to_cheque` on a code outside {XOF, USD, EUR}, with
179//!   Python's message verbatim. `to_currency` never raises it (bug: it falls
180//!   back to XOF).
181//! * `Value` — `int()` in `to_currency` choking on an exponent-notation literal
182//!   (`int("1e+21")`). See `parse_int` and the port report for the half of this
183//!   that is *not* reproducible across the `CurrencyValue` boundary.
184//!
185//! # Cross-call mutable state
186//!
187//! None. `Num2Word_FF` defines no `str_to_number` override and stashes no
188//! flags between calls; every method is a pure function of its argument. The
189//! stateless Rust path is safe to dispatch to.
190
191use crate::base::{Lang, N2WError, Result};
192use crate::currency::{CurrencyForms, CurrencyValue};
193use crate::floatpath::FloatValue;
194use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
195use num_bigint::BigInt;
196use num_integer::Integer;
197use num_traits::{One, Signed, ToPrimitive, Zero};
198use std::collections::HashMap;
199use std::sync::OnceLock;
200
201/// `setup(): self.ones`. Index 0 is the empty string, matching Python; it is
202/// unreachable from `int_to_word` because `number == 0` returns "sufri" first
203/// and every other index path guards against a zero digit.
204const ONES: [&str; 10] = [
205    "", "go'o", "ɗiɗi", "tati", "nayi", "jowi", "jeegoo", "jeeɗiɗi", "jeetati", "jeenayi",
206];
207
208/// `setup(): self.tens`. Index 0 is the empty string (unreachable: `number
209/// < 10` is handled before the tens branch). Note 10 and 20 are suppletive
210/// ("sappo", "noogaas") while 30..90 are regular `"capanɗe " + ONES[n]`.
211const TENS: [&str; 10] = [
212    "",
213    "sappo",
214    "noogaas",
215    "capanɗe tati",
216    "capanɗe nayi",
217    "capanɗe jowi",
218    "capanɗe jeegoo",
219    "capanɗe jeeɗiɗi",
220    "capanɗe jeetati",
221    "capanɗe jeenayi",
222];
223
224/// `setup(): self.hundred`.
225const HUNDRED: &str = "teemerre";
226/// `setup(): self.thousand`.
227const THOUSAND: &str = "ujunere";
228/// `setup(): self.million`.
229const MILLION: &str = "miliyon";
230/// `setup(): self.negword`. The trailing space is load-bearing — Python
231/// concatenates it directly (`self.negword + self.to_cardinal(...)`) with no
232/// separator of its own.
233const NEGWORD: &str = "less ";
234/// `setup(): self.pointword`. Only reachable on the float path, which is out
235/// of scope; carried for fidelity.
236const POINTWORD: &str = "feccere";
237/// `_int_to_word(0)`. Also the per-digit word for `0` on the float path.
238const ZERO_WORD: &str = "sufri";
239/// The ordinal suffix, appended with **no** separating space by both
240/// `to_ordinal` and `to_ordinal_num`.
241const ORDINAL_SUFFIX: &str = "ɓal";
242
243/// Port of `Num2Word_FF._int_to_word`.
244///
245/// Callers only ever reach this with a non-negative value: `to_cardinal`
246/// strips the sign before recursing, and every internal recursion passes a
247/// quotient or remainder of a non-negative dividend. `div_rem` therefore
248/// agrees with Python's `divmod` (they differ only for negative operands).
249fn int_to_word(number: &BigInt) -> String {
250    // if number == 0: return "sufri"
251    if number.is_zero() {
252        return ZERO_WORD.to_string();
253    }
254
255    // if number < 10: return self.ones[number]
256    if *number < BigInt::from(10) {
257        let i = number
258            .to_usize()
259            .expect("value proven < 10 by the guard above");
260        return ONES[i].to_string();
261    }
262
263    // if number < 100:
264    //     t, o = divmod(number, 10)
265    //     return self.tens[t] + (" e " + self.ones[o] if o else "")
266    if *number < BigInt::from(100) {
267        let (t, o) = number.div_rem(&BigInt::from(10));
268        let t = t.to_usize().expect("quotient of a value < 100 by 10 is < 10");
269        let o = o.to_usize().expect("remainder mod 10 is < 10");
270        let mut s = TENS[t].to_string();
271        if o != 0 {
272            s.push_str(" e ");
273            s.push_str(ONES[o]);
274        }
275        return s;
276    }
277
278    // if number < 1000:
279    //     h, r = divmod(number, 100)
280    //     base = self.hundred + (" " + self.ones[h] if h > 1 else "")
281    //     return base + (" e " + self._int_to_word(r) if r else "")
282    if *number < BigInt::from(1000) {
283        let (h, r) = number.div_rem(&BigInt::from(100));
284        let h = h
285            .to_usize()
286            .expect("quotient of a value < 1000 by 100 is < 10");
287        let mut s = HUNDRED.to_string();
288        // NB: `h > 1`, so 100 is bare "teemerre" with no "go'o".
289        if h > 1 {
290            s.push(' ');
291            s.push_str(ONES[h]);
292        }
293        if !r.is_zero() {
294            s.push_str(" e ");
295            s.push_str(&int_to_word(&r));
296        }
297        return s;
298    }
299
300    // if number < 1000000:
301    //     t, r = divmod(number, 1000)
302    //     base = self.thousand + (" " + self._int_to_word(t) if t > 1 else "")
303    //     return base + (" e " + self._int_to_word(r) if r else "")
304    if *number < BigInt::from(1_000_000) {
305        let (t, r) = number.div_rem(&BigInt::from(1000));
306        let mut s = THOUSAND.to_string();
307        // NB: `t > 1`, so 1000 is bare "ujunere" with no "go'o".
308        if t > BigInt::one() {
309            s.push(' ');
310            s.push_str(&int_to_word(&t));
311        }
312        if !r.is_zero() {
313            s.push_str(" e ");
314            s.push_str(&int_to_word(&r));
315        }
316        return s;
317    }
318
319    // if number < 1000000000:
320    //     m, r = divmod(number, 1000000)
321    //     base = self._int_to_word(m) + " " + self.million
322    //     return base + (" e " + self._int_to_word(r) if r else "")
323    if *number < BigInt::from(1_000_000_000) {
324        let (m, r) = number.div_rem(&BigInt::from(1_000_000));
325        // NB: no `m > 1` guard here, unlike the two branches above — this is
326        // why 10**6 is "go'o miliyon" but 10**3 is bare "ujunere".
327        let mut s = int_to_word(&m);
328        s.push(' ');
329        s.push_str(MILLION);
330        if !r.is_zero() {
331            s.push_str(" e ");
332            s.push_str(&int_to_word(&r));
333        }
334        return s;
335    }
336
337    // return str(number)
338    //
339    // The 10^9 cliff: no words, no exception, just the decimal digits.
340    number.to_string()
341}
342
343/// Python's `int(s)` on a fragment of `str(val)`, inside `to_currency`.
344///
345/// `int()` accepts only a plain digit string, so an exponent-notation literal
346/// raises ValueError rather than parsing. Python quotes the offending literal
347/// in the message, and `BigDecimal`'s own rendering supplies the same text for
348/// the `e+` case (see `to_currency`), so the message survives intact too.
349fn parse_int(s: &str) -> Result<BigInt> {
350    s.parse::<BigInt>()
351        .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
352}
353
354/// Python's `int(digit)` on a *single* character of the fractional string,
355/// inside `Num2Word_FF.to_cardinal`'s float branch.
356///
357/// `int('5')` -> 5; `int('e')` raises `ValueError` with the character quoted.
358/// This is how a `str(number)` in scientific notation (`"1.5e+16"`) blows up:
359/// the `'e'` is fed to `int()` per-digit. `char::to_digit(10)` accepts exactly
360/// the ASCII `0-9` that our reconstructed decimal string ever contains; any
361/// other character reproduces Python's message verbatim.
362fn digit_word(ch: char) -> Result<&'static str> {
363    match ch.to_digit(10) {
364        // `self.ones[int(digit)] or "sufri"` — ONES[0] is "" (falsy) -> "sufri".
365        Some(0) => Ok(ZERO_WORD),
366        Some(d) => Ok(ONES[d as usize]),
367        None => Err(N2WError::Value(format!(
368            "invalid literal for int() with base 10: '{}'",
369            ch
370        ))),
371    }
372}
373
374/// Port of `Num2Word_FF.to_cardinal` on the *string* it produced from
375/// `str(number)`. Reproduces the method verbatim, recursion and all:
376///
377/// ```python
378/// n = str(number).strip()
379/// if n.startswith("-"):
380///     return (self.negword + self.to_cardinal(n[1:])).strip()
381/// if "." in n:
382///     left, right = n.split(".", 1)
383///     ret = self._int_to_word(int(left)) + " " + self.pointword
384///     for digit in right:
385///         ret += " " + (self.ones[int(digit)] or "sufri")
386///     return ret.strip()
387/// return self._int_to_word(int(n))
388/// ```
389///
390/// The float/Decimal dispatcher reconstructs `str(number)` (see
391/// `to_cardinal_float`) and hands it here; `s` is already whitespace-free, so
392/// the leading `.strip()` is a no-op. `split(".", 1)` keeps only the first two
393/// parts, matching `split_once`.
394fn to_cardinal_from_str(s: &str) -> Result<String> {
395    // if n.startswith("-"): (self.negword + self.to_cardinal(n[1:])).strip()
396    if let Some(rest) = s.strip_prefix('-') {
397        let inner = to_cardinal_from_str(rest)?;
398        return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
399    }
400    // if "." in n:
401    if let Some((left, right)) = s.split_once('.') {
402        // ret = self._int_to_word(int(left)) + " " + self.pointword
403        let left_int = parse_int(left)?;
404        let mut ret = format!("{} {}", int_to_word(&left_int), POINTWORD);
405        // for digit in right: ret += " " + (self.ones[int(digit)] or "sufri")
406        for ch in right.chars() {
407            ret.push(' ');
408            ret.push_str(digit_word(ch)?);
409        }
410        return Ok(ret.trim().to_string());
411    }
412    // return self._int_to_word(int(n))
413    Ok(int_to_word(&parse_int(s)?))
414}
415
416/// Reconstruct Python's `str(number)` from a `FloatValue`.
417///
418/// `Num2Word_FF.to_cardinal` does no float arithmetic at all — it `str()`s the
419/// input and splits on `"."`. So this port must reproduce `str`, **not**
420/// `base.float2tuple`: the base path's `abs(value-pre)*10**precision` loses a
421/// low-order digit at large magnitude (e.g. `55258828724.79639` -> base yields
422/// `...jeetati`, FF's `str` yields `...jeenayi`). Both branches below rebuild
423/// the exact decimal `repr`, so no rounding heuristic is involved.
424fn float_to_str(v: &FloatValue) -> String {
425    match v {
426        // `str(float)`: shortest round-trip repr with exactly `precision`
427        // fractional digits. `format!("{:.*}", precision, mag)` reproduces it
428        // for the fixed-notation range (verified digit-for-digit against
429        // CPython over 5e5 samples). `precision` is the repr's own fraction
430        // length, so re-formatting to that many places recovers the same
431        // digits without a genuine rounding boundary. Scientific-notation
432        // magnitudes are out of range here — see the port report.
433        FloatValue::Float { value, precision } => {
434            let neg = *value < 0.0 || (*value == 0.0 && value.is_sign_negative());
435            let body = format!("{:.*}", *precision as usize, value.abs());
436            format!("{}{}", if neg { "-" } else { "" }, body)
437        }
438        // `str(Decimal)`: fixed-point with exactly `precision` fractional
439        // digits, trailing zeros preserved (`Decimal("1.10")` -> `"1.10"`, not
440        // `"1.1"`). Forcing the scale to `precision` restores any trailing zero
441        // the `BigDecimal` may have normalised away, then the mantissa digits
442        // are placed around a decimal point by hand — avoiding any dependence
443        // on `BigDecimal`'s own `Display`.
444        FloatValue::Decimal { value, precision } => {
445            let p = *precision as usize;
446            let (mant, _scale) = value.with_scale(*precision as i64).as_bigint_and_exponent();
447            let neg = mant.is_negative();
448            let mut digits = mant.abs().to_string(); // ASCII 0-9, no sign
449            let body = if p == 0 {
450                digits
451            } else {
452                // ljust the *integer* side: need at least one digit before the
453                // point, i.e. len > p.
454                while digits.len() <= p {
455                    digits.insert(0, '0');
456                }
457                let split = digits.len() - p;
458                format!("{}.{}", &digits[..split], &digits[split..])
459            };
460            format!("{}{}", if neg { "-" } else { "" }, body)
461        }
462    }
463}
464
465/// Python `repr()` of a **non-negative** f64 — the string FF's `to_cardinal`
466/// sees after the sign is detached. Rust's `{}` produces the same
467/// shortest-round-trip digits but never switches to exponent form and drops
468/// the ".0" of whole values, so the two Python-isms are reapplied here:
469///
470/// * exponent form for `|v| >= 1e16` or `0 < |v| < 1e-4`, with the sign
471///   always shown and the exponent zero-padded to two digits (`"1e+16"`,
472///   `"1.5e-05"`) — exactly repr's thresholds;
473/// * a trailing `".0"` for whole values in positional form (`"5.0"`).
474///
475/// inf/nan come back as `"inf"`/`"nan"`, which `parse_int` then rejects with
476/// the same ValueError Python's `int()` would raise.
477fn python_float_repr_abs(f: f64) -> String {
478    if f.is_nan() {
479        return "nan".to_string();
480    }
481    if f.is_infinite() {
482        return "inf".to_string();
483    }
484    if f != 0.0 && (f >= 1e16 || f < 1e-4) {
485        let s = format!("{:e}", f); // "1e16" / "1.2345e-7"
486        let (mant, exp) = s.split_once('e').expect("LowerExp always emits an e");
487        let exp: i32 = exp.parse().expect("f64 exponent is a small integer");
488        return format!(
489            "{}e{}{:02}",
490            mant,
491            if exp < 0 { '-' } else { '+' },
492            exp.abs()
493        );
494    }
495    let s = format!("{}", f);
496    if s.contains('.') {
497        s
498    } else {
499        format!("{}.0", s)
500    }
501}
502
503/// Python's `str(number)` for either `FloatValue` arm, sign included — the
504/// exact string `Num2Word_FF.to_cardinal` receives. Float goes through the
505/// repr rules above (with the sign-bit-aware minus, so `-0.0` keeps its
506/// sign); Decimal is the spec `to-scientific-string` algorithm, which is why
507/// `Decimal("1E+2")` reads back `"1E+2"` and dies in `int()` exactly like
508/// Python.
509fn py_str(value: &FloatValue) -> String {
510    match value {
511        FloatValue::Float { value: f, .. } => {
512            let body = python_float_repr_abs(f.abs());
513            if f.is_sign_negative() {
514                format!("-{}", body)
515            } else {
516                body
517            }
518        }
519        FloatValue::Decimal { value: d, .. } => python_decimal_str(d),
520    }
521}
522
523/// Fulah (Pulaar/Fulfulde) — `CONVERTER_CLASSES["ff"]` → `Num2Word_FF`.
524///
525/// The numeral tables are module constants (the converter holds no mutable
526/// state across calls — see the module docs); only `CURRENCY_FORMS` needs to
527/// live on the instance, because it is a map and Python's fallback depends on
528/// its *insertion order*.
529pub struct LangFf {
530    /// `Num2Word_FF.CURRENCY_FORMS`, built once in `new()`. The registry caches
531    /// the instance behind a `OnceLock`, so this runs at most once per process;
532    /// rebuilding it per call is what made an earlier revision of this port
533    /// slower than the Python it replaces.
534    currency_forms: HashMap<&'static str, CurrencyForms>,
535    /// `list(self.CURRENCY_FORMS.values())[0]` — the **XOF** entry, i.e. the
536    /// first value in the class-body dict literal's insertion order.
537    /// `to_currency` silently falls back to it for any unknown code. Held
538    /// separately because a `HashMap` has no insertion order to recover it from.
539    currency_forms_fallback: CurrencyForms,
540}
541
542impl Default for LangFf {
543    fn default() -> Self {
544        Self::new()
545    }
546}
547
548impl LangFf {
549    pub fn new() -> Self {
550        // Class-body insertion order is XOF, USD, EUR. Only the first entry's
551        // identity matters beyond lookup (it is `.get()`'s default), and that is
552        // captured in `currency_forms_fallback` rather than left to the map.
553        //
554        // Both forms of every entry are identical in the Python source; the
555        // duplication is transcribed rather than collapsed, because
556        // `to_currency` indexes `cr1[1]`/`cr2[1]` directly and the arity is
557        // what keeps that index in range.
558        const SANTIIM: [&str; 2] = ["santiim", "santiim"];
559        let xof = CurrencyForms::new(&["seefaa", "seefaa"], &SANTIIM);
560
561        let mut currency_forms = HashMap::new();
562        currency_forms.insert("XOF", xof.clone());
563        currency_forms.insert("USD", CurrencyForms::new(&["dolaar", "dolaar"], &SANTIIM));
564        currency_forms.insert("EUR", CurrencyForms::new(&["yero", "yero"], &SANTIIM));
565
566        LangFf {
567            currency_forms,
568            currency_forms_fallback: xof,
569        }
570    }
571}
572
573impl Lang for LangFf {
574    /// This language's own `to_currency(currency=...)` default,
575    /// read from the live Python signature. Only 44 of 156 use EUR.
576    fn default_currency(&self) -> &str {
577        "XOF"
578    }
579
580    /// This language's own `to_currency(separator=...)` default,
581    /// read from the live Python signature. Base's is ",", but only
582    /// 36 of 149 languages actually use it — most default to " " or a
583    /// conjunction, so inheriting Base's comma silently corrupts them.
584    fn default_separator(&self) -> &str {
585        " "
586    }
587
588    fn negword(&self) -> &str {
589        NEGWORD
590    }
591
592    fn pointword(&self) -> &str {
593        "feccere"
594    }
595
596    /// `setup(): self.exclude_title = ["e", "feccere", "less"]`.
597    ///
598    /// Reproduced for fidelity only. `is_title` stays `False` (the trait
599    /// default, matching `__init__`), and `Num2Word_FF.to_cardinal` never
600    /// calls `title()` anyway, so this list is never consulted.
601    fn exclude_title(&self) -> &[String] {
602        static EXCL: OnceLock<Vec<String>> = OnceLock::new();
603        EXCL.get_or_init(|| {
604            vec!["e".to_string(), "feccere".to_string(), "less".to_string()]
605        })
606    }
607
608    /// Port of `Num2Word_FF.to_cardinal`, integer path only.
609    ///
610    /// Python stringifies the input and branches on the leading `"-"` and on
611    /// the presence of `"."`. `str(int)` never contains a `"."`, so integers
612    /// always fall through to `self._int_to_word(int(n))`. The float branch
613    /// (`pointword` + per-digit `ones`, with `"sufri"` for a zero digit) is
614    /// out of scope.
615    ///
616    /// The negative branch recurses through `to_cardinal` on the *digit
617    /// string* `n[1:]`, which re-enters the same integer path with the
618    /// absolute value — so it is equivalent to prefixing `int_to_word(|n|)`.
619    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
620        if value.is_negative() {
621            // (self.negword + self.to_cardinal(n[1:])).strip()
622            let inner = int_to_word(&value.abs());
623            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
624        }
625        Ok(int_to_word(value))
626    }
627
628    /// Port of `Num2Word_FF.to_ordinal`: `self.to_cardinal(number) + "ɓal"`.
629    ///
630    /// No separator, no stem change, and no guard against negatives or zero —
631    /// `to_ordinal(0)` == `"sufriɓal"` and `to_ordinal(-1)` == `"less go'oɓal"`
632    /// (both in the corpus). Above the 10^9 cliff it degenerates to digits
633    /// plus the suffix, making it identical to `to_ordinal_num`.
634    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
635        let card = self.to_cardinal(value)?;
636        Ok(format!("{}{}", card, ORDINAL_SUFFIX))
637    }
638
639    /// Port of `Num2Word_FF.to_ordinal_num`: `str(number) + "ɓal"`.
640    ///
641    /// Overrides the base default (`return value`) only by appending the
642    /// suffix. The sign is kept verbatim: `to_ordinal_num(-1)` == `"-1ɓal"`.
643    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
644        Ok(format!("{}{}", value, ORDINAL_SUFFIX))
645    }
646
647    /// Port of `Num2Word_FF.to_year`: `return self.to_cardinal(val)`.
648    ///
649    /// The `longval=True` parameter is accepted and ignored by Python, and
650    /// the base class's AD/BC handling is bypassed entirely — negative years
651    /// simply come back with the `"less "` prefix (`year(-44)` ==
652    /// `"less capanɗe nayi e nayi"`), not a "BC" marker. This matches the
653    /// trait default, but is spelled out so the override is not mistaken for
654    /// an omission.
655    fn to_year(&self, value: &BigInt) -> Result<String> {
656        self.to_cardinal(value)
657    }
658
659    /// The float/Decimal branch of `Num2Word_FF.to_cardinal`.
660    ///
661    /// FF does **not** override `to_cardinal_float`; it overrides `to_cardinal`
662    /// and handles non-integers inline via `str(number).split(".", 1)`. So the
663    /// inherited `default_to_cardinal_float` (which drives `base.float2tuple`)
664    /// is the wrong engine here — it rounds the fraction through binary
665    /// arithmetic and drops a low-order digit at large magnitude. This override
666    /// instead rebuilds `str(number)` and runs FF's own string algorithm.
667    ///
668    /// `precision_override` is ignored: `Num2Word_FF.to_cardinal(self, number)`
669    /// takes no `precision=` parameter, so the kwarg never reaches this code in
670    /// Python (it would `TypeError` at the call site). The reconstructed string
671    /// uses the value's repr-derived precision, exactly as `str()` does.
672    ///
673    /// Faithfully reproduced quirks:
674    ///   * `1.0` (float) -> `"go'o feccere sufri"`: `str(1.0)` is `"1.0"`, so the
675    ///     `"."` branch fires and a trailing `"0"` digit -> `"sufri"`.
676    ///   * `Decimal("1.10")` -> `"go'o feccere go'o sufri"`: the trailing zero is
677    ///     a real fractional digit (unlike the float `1.1`).
678    ///   * `Decimal("98746251323029.99")` -> `"98746251323029 feccere jeenayi
679    ///     jeenayi"`: the >=10^9 integer part falls off `_int_to_word`'s cliff to
680    ///     bare digits, but the fraction is still spelled (issue #603 value).
681    ///   * A negative with a zero integer part keeps its sign because the sign
682    ///     lives in the *string* (`"-0.5"`), not in a truncated int:
683    ///     `-0.5` -> `"less sufri feccere jowi"`.
684    fn to_cardinal_float(
685        &self,
686        value: &FloatValue,
687        _precision_override: Option<u32>,
688    ) -> Result<String> {
689        to_cardinal_from_str(&float_to_str(value))
690    }
691
692    /// `to_cardinal(float/Decimal)` — the FULL routing, whole values included.
693    ///
694    /// FF routes on the string, not the value: `"." in str(number)` decides
695    /// between the pointword grammar and `int(n)`. So the base default's
696    /// whole→int shortcut is wrong here — `str(5.0)` is `"5.0"` and must read
697    /// `"jowi feccere sufri"`, while the point-free `Decimal("5")` stays
698    /// `"jowi"`, and an exponent-form repr (`"1e+16"`, `"1E+2"`) raises
699    /// `ValueError` from `int()`. `to_cardinal_from_str` reproduces the whole
700    /// method (sign recursion, `int(left)`, per-digit `int()`), so this entry
701    /// only has to rebuild Python's `str(number)`.
702    ///
703    /// `precision_override` is ignored: `Num2Word_FF.to_cardinal(self, number)`
704    /// takes no `precision=` parameter, so the kwarg never reaches this code in
705    /// Python.
706    fn cardinal_float_entry(
707        &self,
708        value: &FloatValue,
709        _precision_override: Option<u32>,
710    ) -> Result<String> {
711        to_cardinal_from_str(&py_str(value))
712    }
713
714    /// `to_ordinal(float/Decimal)`: `to_cardinal(number) + "ɓal"`, no special
715    /// cases — `to_ordinal(5.0)` == `"jowi feccere sufriɓal"`, and an
716    /// exponent-form repr propagates the cardinal's ValueError.
717    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
718        Ok(format!(
719            "{}{}",
720            self.cardinal_float_entry(value, None)?,
721            ORDINAL_SUFFIX
722        ))
723    }
724
725    /// `to_ordinal_num(float/Decimal)`: `str(number) + "ɓal"` — the raw repr,
726    /// never an error: `to_ordinal_num(1e16)` == `"1e+16ɓal"`.
727    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
728        Ok(format!("{}{}", repr_str, ORDINAL_SUFFIX))
729    }
730
731    /// `to_year(float/Decimal)`: bare `self.to_cardinal(val)`, string routing
732    /// included — `to_year(5.0)` == `"jowi feccere sufri"`, `to_year(1e16)`
733    /// raises ValueError.
734    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
735        self.cardinal_float_entry(value, None)
736    }
737
738    /// `str_to_number` is inherited from the base (`Decimal(value)`) and this
739    /// override does not change what parses — `python_decimal_parse` still
740    /// decides. It exists because `Decimal("Infinity")`/`Decimal("NaN")` *do*
741    /// parse in Python, and FF's `to_cardinal` then dies at `int("Infinity")`
742    /// with `ValueError: invalid literal for int() with base 10: 'Infinity'`
743    /// (str(Decimal) capitalises whatever the input case, and the sign of
744    /// "-Infinity" is stripped before `int()` sees it). The bridge hard-wires
745    /// the *base* integer-path errors for Inf/NaN parses (OverflowError /
746    /// "cannot convert NaN"), which FF never produces, so the raise is
747    /// surfaced here instead. The one input this would misserve is
748    /// `to_ordinal_num("Infinity")` (Python: `"Infinityɓal"`), which no corpus
749    /// exercises.
750    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
751        match python_decimal_parse(s)? {
752            ParsedNumber::Inf { .. } => Err(N2WError::Value(
753                "invalid literal for int() with base 10: 'Infinity'".into(),
754            )),
755            ParsedNumber::NaN => Err(N2WError::Value(
756                "invalid literal for int() with base 10: 'NaN'".into(),
757            )),
758            other => Ok(other),
759        }
760    }
761
762    // ---- currency ---------------------------------------------------------
763    //
764    // `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are Base's empty dicts at
765    // runtime, so `currency_adjective` (None) and `currency_precision` (100 for
766    // every code) are left at their trait defaults, which already say exactly
767    // that. `_money_verbose`/`_cents_verbose`/`_cents_terse` are inherited
768    // unchanged and their trait defaults already mirror Base. `to_cheque` is
769    // inherited too, so `currency::default_to_cheque` serves it via the two
770    // hooks below. `cardinal_from_decimal` stays at its raising default: FF's
771    // `to_currency` never reaches base's fractional-cents branch (it has no
772    // fractional-cents concept at all — bug 5 truncates at two digits).
773
774    /// `self.__class__.__name__`, for the NotImplementedError `to_cheque`
775    /// raises. `to_currency` never raises it — see `currency_forms`.
776    fn lang_name(&self) -> &str {
777        "Num2Word_FF"
778    }
779
780    /// `self.CURRENCY_FORMS[currency]` — a **strict** subscript, matching the
781    /// `try: cr1, _cr2 = self.CURRENCY_FORMS[currency] except KeyError:` in
782    /// `Num2Word_Base.to_cheque`, which is this hook's only caller here.
783    /// `to_currency` deliberately does *not* route through it: it uses `.get()`
784    /// with an XOF default and never raises.
785    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
786        self.currency_forms.get(code)
787    }
788
789    /// Python:
790    /// ```python
791    /// def pluralize(self, n, forms):
792    ///     if not forms:
793    ///         return ""
794    ///     return forms[0] if n == 1 else forms[-1]
795    /// ```
796    ///
797    /// Overriding `Num2Word_Base.pluralize`'s bare `raise NotImplementedError`,
798    /// so the trait's raising default would be wrong here. Unreachable in
799    /// practice — FF overrides `to_currency` wholesale and picks its form by
800    /// hand, and `to_cheque` takes `cr1[-1]` without consulting this — but the
801    /// method exists on the class and is transcribed for that reason.
802    ///
803    /// Note `forms[-1]` (**last**), not `forms[1]`: with a 3-form entry this
804    /// would differ from `Num2Word_EUR.pluralize`. It also never raises
805    /// IndexError, because the empty case returns `""` first.
806    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
807        match forms {
808            [] => Ok(String::new()),
809            _ if n.is_one() => Ok(forms[0].clone()),
810            _ => Ok(forms[forms.len() - 1].clone()),
811        }
812    }
813
814    /// Python's `Num2Word_FF.to_currency`:
815    ///
816    /// ```python
817    /// def to_currency(self, val, currency="XOF", cents=True, separator=" ",
818    ///                 adjective=False):
819    ///     is_negative = val < 0
820    ///     val = abs(val)
821    ///     parts = str(val).split(".")
822    ///     left = int(parts[0]) if parts[0] else 0
823    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
824    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
825    ///     result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
826    ///     if cents and right:
827    ///         result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
828    ///     if is_negative:
829    ///         result = self.negword + result
830    ///     return result.strip()
831    /// ```
832    ///
833    /// This shares nothing with `Num2Word_Base.to_currency`. It is a
834    /// string-level reimplementation, and the `str(val)` it splits is what makes
835    /// the Int/Decimal distinction reach here at all: `str(1)` == `"1"` has one
836    /// part (`right` = 0), `str(1.0)` == `"1.0"` has two — which then *also*
837    /// yields `right` = 0 via `int("0".ljust(2, "0"))`, so both print
838    /// `"go'o yero"` (bug 6). They agree, but by different routes, and only when
839    /// the cents are zero.
840    ///
841    /// The words come from `_int_to_word`, **not** `to_cardinal`, so they
842    /// inherit the silent digit fallback above 10^9 without inheriting the
843    /// negword handling: `to_currency(1000000000.0, "EUR")` is
844    /// `"1000000000 yero"`. `is_negative` is captured from the *original* value
845    /// and re-applied at the end, which is why taking `abs` up front is safe.
846    fn to_currency(
847        &self,
848        val: &CurrencyValue,
849        currency: &str,
850        cents: bool,
851        separator: Option<&str>,
852        _adjective: bool, // declared and never read by Python (bug 8)
853    ) -> Result<String> {
854        // The trait hands us None when the caller omitted `separator=`; resolve
855        // it through this language's own default (" ") before the ported body.
856        let separator = separator.unwrap_or(self.default_separator());
857
858        // `is_negative = val < 0` is evaluated *before* `val = abs(val)`.
859        let is_negative = val.is_negative();
860
861        // `str(abs(val))`. The Decimal arm was parsed from Python's own
862        // `str(value)` on the shim side, so rendering it back reproduces the
863        // literal for every plain decimal string. It reproduces the `e+` case
864        // too — `BigDecimal` keeps a negative scale and re-renders "1e+21",
865        // which `parse_int` then rejects exactly as Python's `int()` does. The
866        // `e-` case is *not* recoverable; see the port report.
867        let s = match val {
868            CurrencyValue::Int(i) => i.abs().to_string(),
869            CurrencyValue::Decimal { value: d, .. } => d.abs().to_string(),
870        };
871
872        // `parts = str(val).split(".")`, then parts[0] / parts[1]. Python splits
873        // on every ".", so parts[1] would stop at a second one; a decimal
874        // rendering never carries two, making this 2-way split equivalent.
875        let mut parts = s.split('.');
876        let p0 = parts.next().unwrap_or("");
877        let p1 = parts.next();
878
879        // `left = int(parts[0]) if parts[0] else 0`
880        let left = if p0.is_empty() {
881            BigInt::zero()
882        } else {
883            parse_int(p0)?
884        };
885
886        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
887        // `[:2]` truncates to two digits — it does not round (bug 5).
888        let right = match p1 {
889            Some(frac) if !frac.is_empty() => {
890                let mut padded: String = frac.chars().take(2).collect();
891                while padded.chars().count() < 2 {
892                    padded.push('0'); // ljust(2, "0")
893                }
894                parse_int(&padded)?
895            }
896            _ => BigInt::zero(),
897        };
898
899        // `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
900        // — an unknown code borrows XOF's forms instead of raising.
901        let forms = self
902            .currency_forms
903            .get(currency)
904            .unwrap_or(&self.currency_forms_fallback);
905        let cr1 = &forms.unit;
906        let cr2 = &forms.subunit;
907
908        // `result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])`
909        // — a direct index, not `pluralize()`. Every FF entry carries exactly
910        // two forms, so `[1]` is in range.
911        let one = BigInt::one();
912        let mut result = format!(
913            "{} {}",
914            int_to_word(&left),
915            if left != one { &cr1[1] } else { &cr1[0] }
916        );
917
918        // `if cents and right:` — `right` is an int, so this is `right != 0`
919        // (bugs 6 and 7).
920        if cents && !right.is_zero() {
921            // Python concatenates the separator raw, with no space of its own
922            // (bug 9).
923            result.push_str(separator);
924            result.push_str(&int_to_word(&right));
925            result.push(' ');
926            result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
927        }
928
929        // `result = self.negword + result` — "less " *with* its trailing space,
930        // concatenated raw, which is what supplies the gap (bug 10).
931        if is_negative {
932            result = format!("{}{}", NEGWORD, result);
933        }
934
935        Ok(result.trim().to_string())
936    }
937}