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}