num2words2_core/lang_fo.rs
1//! Port of `lang_FO.py` (Faroese).
2//!
3//! Shape: **self-contained**. `Num2Word_FO` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `hasattr` guard in `Num2Word_Base.__init__` never fires: Python builds no
6//! `self.cards` and never sets `self.MAXVAL`. `to_cardinal` is overridden
7//! outright and drives a plain recursive `_int_to_word`. Consequently
8//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
9//! **no overflow check** — `to_cardinal` never raises for any integer input
10//! (see bug 3 below for what happens past 10^9 instead).
11//!
12//! All four in-scope modes are overridden by Python, so nothing here falls
13//! through to a `Num2Word_Base` default:
14//! * `to_cardinal(number)` → strips the sign off `str(number)`, recurses.
15//! * `to_ordinal(number)` → `to_cardinal(number) + "-ti"`, unconditionally.
16//! * `to_ordinal_num(number)` → `str(number) + "."`.
17//! * `to_year(val, longval=True)` → `to_cardinal(val)`; `longval` is accepted
18//! and then ignored, so years get no century split and no era suffix.
19//!
20//! # Faithfully reproduced Python bugs
21//!
22//! This is a port, not a rewrite. Everything below is wrong — as Faroese and
23//! sometimes as arithmetic — but is exactly what Python emits, verified
24//! against the frozen corpus:
25//!
26//! 1. **There are no teens.** `self.tens[1]` is "tíggju" (= 10) and 11..=19 are
27//! built by the generic `tens[n/10] + " " + ones[n%10]` rule, so
28//! `to_cardinal(11)` == "tíggju ein" — literally "ten one". Real Faroese is
29//! "ellivu". Likewise 12 → "tíggju tvey" (not "tólv"), 13 → "tíggju trý"
30//! (not "trettan"), ... 19 → "tíggju níggju" (not "nítjan"). This leaks
31//! upward: `to_cardinal(12345)` == "tíggju tvey túsund trý hundrað fjøruti
32//! fimm", i.e. the thousands part reads "ten two thousand".
33//! 2. **No copula anywhere.** Faroese joins the last two elements with "og"
34//! ("hundrað og ein"); this module joins every fragment with a bare space,
35//! so 101 → "ein hundrað ein". Numerals are also never inflected for gender
36//! ("tvey"/"trý" are the neuter forms, used for everything).
37//! 3. **Everything >= 10^9 falls back to digits.** The `_int_to_word` chain
38//! stops at `million`, and the final `else` is `return str(number)` — a
39//! literal comment-flagged "Fallback for very large numbers". So
40//! `to_cardinal(10**9)` == "1000000000" (a digit string, not words) and
41//! `to_cardinal(10**21)` == "1000000000000000000000". No exception is
42//! raised. Modelled by the last arm of [`LangFo::int_to_word`].
43//! 4. **The 10^9 fallback poisons the ordinal.** `to_ordinal` appends "-ti" to
44//! whatever `to_cardinal` returned without inspecting it, so
45//! `to_ordinal(10**9)` == "1000000000-ti" — digits with a word suffix glued
46//! on. Confirmed in the corpus.
47//! 5. **`to_ordinal` accepts negatives.** Python never calls
48//! `Num2Word_Base.verify_ordinal`, which would have raised `TypeError` for
49//! negative input. So `to_ordinal(-1)` == "minus ein-ti" rather than
50//! raising, and `to_ordinal_num(-1)` == "-1." (`str(-1) + "."`).
51//! 6. **"-ti" is not an ordinal suffix**, it is the tens suffix ("fimmti" =
52//! 50). Every ordinal is formed this way regardless of the stem, so
53//! `to_ordinal(1)` == "ein-ti" where Faroese wants "fyrsti". The hyphen is
54//! literal and always present.
55//! 7. `zero` is an English word. `_int_to_word(0)` reads
56//! `self.ones[0] if self.ones[0] else "zero"`; `ones[0]` is `""` — falsy —
57//! so the guard *always* takes the fallback and 0 → "zero", never the
58//! Faroese "null". `pointword` is likewise the English "point".
59//!
60//! # Error variants
61//!
62//! The four *integer* modes never raise: there is no `MAXVAL` to overflow,
63//! every table index is bounded by its enclosing branch, and negatives are
64//! handled rather than rejected. The integer corpus for "fo" is `ok: true`
65//! across `cardinal`/`ordinal`/`ordinal_num`/`year`.
66//!
67//! The **float/Decimal** path *can* raise, and only one way: `to_cardinal`
68//! re-parses the sign-stripped `str(number)` with `int()`, so any point-less
69//! form that is not an integer literal — `repr(1e16)` == "1e+16",
70//! `str(Decimal("1E+2"))` == "1E+2", "inf"/"nan"/"Infinity" — raises
71//! `ValueError: invalid literal for int() with base 10: '...'`. Values whose
72//! string *does* show a point never raise, whatever their size (the >= 10^9
73//! integer part just leaks digits, bug 3). Routing is pinned by the
74//! wholefloat corpus: `13x ValueError` rows for 1e+16/1e+20/1E+2/1E+20 across
75//! cardinal/ordinal/year, and `ok` rows for every pointed form including
76//! "-0.0" -> "minus zero point zero" (see [`route_by_str`]).
77//!
78//! `to_currency` never raises either — see bug 8 below, it has no
79//! `NotImplementedError` path at all. The **only** raising surface in this
80//! module is `to_cheque`, which `Num2Word_FO` does *not* override: the
81//! inherited `Num2Word_Base.to_cheque` subscripts `CURRENCY_FORMS[currency]`
82//! and converts the `KeyError` into `NotImplementedError`. So `cheque:GBP`
83//! raises while `currency:GBP` happily prints krónur.
84//!
85//! # Currency surface
86//!
87//! `Num2Word_FO` overrides `to_currency` **wholesale** — it never calls
88//! `Num2Word_Base.to_currency`, `parse_currency_parts`, `pluralize`,
89//! `_cents_verbose` or `_cents_terse`. Consequently:
90//!
91//! * `pluralize` is never reached and stays at its (raising) trait default,
92//! exactly as `Num2Word_FO` leaves `Num2Word_Base.pluralize` abstract.
93//! * `CURRENCY_PRECISION` is `{}` (inherited from `Num2Word_Base`), so
94//! `.get(code, 100)` is always 100 — `currency_precision` stays at the trait
95//! default. FO's `to_currency` does not consult it anyway (bug 9).
96//! * `CURRENCY_ADJECTIVES` is `{}` and `adjective` is accepted-then-ignored
97//! (bug 10), so `currency_adjective` stays at its `None` default.
98//! * `money_verbose` stays at its default (`self.to_cardinal`), which is what
99//! `to_cheque` needs — `Num2Word_FO` does not override `_money_verbose`.
100//!
101//! ## Further faithfully reproduced Python bugs
102//!
103//! 8. **An unknown currency code silently becomes DKK.** FO's `to_currency`
104//! reads `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
105//! — a `.get` with a *default*, not a subscript. `CURRENCY_FORMS` is
106//! declared `{"DKK": ..., "USD": ..., "EUR": ...}` and dicts preserve
107//! insertion order, so `values()[0]` is DKK's pair. Every unrecognised code
108//! is therefore rendered in Faroese krónur under its own name:
109//! `to_currency(1.0, "GBP")` == "ein króna", `to_currency(12.34, "JPY")` ==
110//! "tíggju tvey krónur tríati fýra oyru". The corpus pins GBP/JPY/KWD/BHD/
111//! INR/CNY/CHF to exactly that. Modelled by [`FALLBACK_CURRENCY`].
112//! 9. **`CURRENCY_PRECISION` is ignored, so every currency splits at 2
113//! decimals.** The cents are cut out of the *digit string* with
114//! `parts[1][:2]`, a hard-coded 2. 3-decimal codes (KWD/BHD) and 0-decimal
115//! ones (JPY) get cent-style treatment like everything else — and since
116//! bug 8 has already rewritten them to DKK, `currency:JPY` for 12.34 is
117//! "tíggju tvey krónur tríati fýra oyru" rather than the ¥12 a JPY-aware
118//! path would give.
119//! 10. **`adjective` is dead.** The parameter is declared and never read, so
120//! `adjective=True` changes nothing (there are no `CURRENCY_ADJECTIVES` to
121//! apply either).
122//! 11. **Cents are dropped whenever they round to zero**, `int` or `float`
123//! alike. The guard is `if cents and right:` — a plain truthiness test on
124//! the cent count, *not* `Num2Word_Base`'s `isinstance(val, int)` check. So
125//! FO is one of the rare languages where `1` and `1.0` agree: both are "ein
126//! euro", never "ein euro null cents". `0.001` likewise truncates to zero
127//! cents and prints "zero euros".
128//! 12. **The cent digits are truncated, never rounded**, and are read
129//! left-to-right off the string: `12.999` → `"999"[:2]` → 99 cents, and
130//! `0.5` → `"5".ljust(2, "0")` → "50" → 50 cents. So a trailing digit is
131//! dropped rather than rounded up (`.999` does not carry to the next unit).
132
133use crate::base::{Lang, N2WError, Result};
134use crate::currency::{CurrencyForms, CurrencyValue};
135use crate::floatpath::FloatValue;
136use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
137use bigdecimal::BigDecimal;
138use num_bigint::BigInt;
139use num_integer::Integer;
140use num_traits::{One, Signed, ToPrimitive, Zero};
141use std::collections::HashMap;
142
143/// `self.negword`. Note the **trailing space**, which is part of the literal
144/// in Python (`"minus "`) and differs from the `Num2Word_Base` convention.
145/// `to_cardinal` concatenates it raw and lets the final `.strip()` tidy up.
146const NEGWORD: &str = "minus ";
147
148/// `self.pointword`. English, verbatim from Python (bug 7's sibling): every
149/// pointed value reads "... point ...", never the Faroese "komma".
150const POINTWORD: &str = "point";
151
152/// `self.ones`. Index 0 is the empty string — see bug 7.
153const ONES: [&str; 10] = [
154 "", "ein", "tvey", "trý", "fýra", "fimm", "seks", "sjey", "átta", "níggju",
155];
156
157/// `self.tens`. Index 1 is "tíggju" (10) and there is no teens table — see
158/// bug 1.
159const TENS: [&str; 10] = [
160 "", "tíggju", "tjúgu", "tríati", "fjøruti", "fimmti", "seksti", "sjeyti", "áttati", "níti",
161];
162
163const HUNDRED: &str = "hundrað";
164const THOUSAND: &str = "túsund";
165const MILLION: &str = "millión";
166
167/// The Python class name, for `Num2Word_Base.to_cheque`'s NotImplementedError
168/// message (`self.__class__.__name__`).
169const LANG_NAME: &str = "Num2Word_FO";
170
171/// `Num2Word_FO.to_currency`'s own default `separator=" "` — a bare space.
172const SEPARATOR_DEFAULT: &str = " ";
173
174/// The separator the pyo3 binding hands us when the Python caller omitted one.
175///
176/// `Num2Word_FO.to_currency` declares `separator=" "`, but the `Lang` trait
177/// carries no per-language parameter defaults, and both `__init__.py`'s
178/// currency fast path and `bench/diff_test.py` substitute
179/// `kwargs.get("separator", ",")` — **`Num2Word_Base`'s** default, not FO's —
180/// before the value crosses the boundary. By then "caller omitted separator"
181/// and "caller explicitly passed a comma" are the same `&str`, and the
182/// information needed to tell them apart no longer exists on this side.
183///
184/// So `,` is read back as the unset sentinel and FO's own default restored.
185/// This is the only reading the oracle supports: every float row of the `fo`
186/// currency corpus was generated by `num2words(v, lang="fo", to="currency",
187/// currency=c)` with no `separator=`, and all 54 of them expect a bare space
188/// ("tíggju tvey euros tríati fýra cents", not "...euros,tríati fýra cents").
189///
190/// The cost is narrow and known: a caller who *explicitly* passes
191/// `separator=","` gets " " where Python would give ",". Expressing that case
192/// needs `Option<&str>` in the trait signature, which lives in `base.rs` —
193/// outside this port's remit. Flagged in the port report. `lang_bo.rs`,
194/// `lang_ca.rs`, `lang_es.rs` and `lang_eu.rs` resolve the identical conflict
195/// the same way.
196const SEPARATOR_UNSET: &str = ",";
197
198/// `to_currency`'s fallback code — see bug 8.
199///
200/// Python spells it `list(self.CURRENCY_FORMS.values())[0]`, i.e. "whichever
201/// entry was inserted first". `CURRENCY_FORMS` is a dict literal ordered
202/// `DKK, USD, EUR` and dicts preserve insertion order, so that is DKK. Pinning
203/// the *key* rather than reproducing a positional lookup keeps the intent
204/// legible; a `HashMap` has no insertion order to index into anyway.
205///
206/// Verified against the live interpreter rather than read off the source:
207/// `list(CONVERTER_CLASSES["fo"].CURRENCY_FORMS.keys())` == `['DKK', 'USD',
208/// 'EUR']`. Nothing mutates this dict — `Num2Word_FO` declares its own
209/// `CURRENCY_FORMS` and inherits from `Num2Word_Base`, not `Num2Word_EUR`, so
210/// the `Num2Word_EN.__init__` shared-dict rewrite documented in
211/// PORTING_CURRENCY.md does not reach it.
212const FALLBACK_CURRENCY: &str = "DKK";
213
214/// The hard-coded 2 in `parts[1][:2].ljust(2, "0")` — see bug 9.
215const CENT_DIVISOR: i64 = 100;
216
217/// Python's `int(s)` failure, message verbatim from CPython. `s` is the
218/// point-less `str(number)` slice `to_cardinal` feeds it — sign already
219/// stripped, because the leading "-" is peeled off the string before `int()`
220/// ever runs.
221fn int_value_error(s: &str) -> N2WError {
222 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
223}
224
225/// Python `repr(float)` for the scientific range, on an already-`abs()`ed
226/// finite f64 (`a >= 1e16`, or `0 < a < 1e-4`).
227///
228/// Rust's `{:e}` yields the shortest round-trip mantissa — the same digits
229/// CPython's repr picks — but lays the exponent out as `1e16` / `1e-5` where
230/// Python writes `1e+16` / `1e-05`: always a sign, zero-padded to two digits.
231/// Only the exponent dressing needs fixing up. (Same helper as `lang_ky.rs`,
232/// whose Python module is the same copy-paste family.)
233fn py_float_sci_repr(a: f64) -> String {
234 let sci = format!("{:e}", a); // "1e16", "1.5e20", "1e-5"
235 let (mant, exp_str) = sci.split_once('e').expect("{:e} always emits an 'e'");
236 let exp: i64 = exp_str.parse().expect("{:e} exponent is a base-10 integer");
237 format!(
238 "{}e{}{:02}",
239 mant,
240 if exp < 0 { '-' } else { '+' },
241 exp.abs()
242 )
243}
244
245/// Where `str(number)` sends the value in `to_cardinal` (sign already peeled).
246enum StrRoute {
247 /// `"." in n` — the decimal grammar spells every fractional character.
248 Point,
249 /// No "." and `int(n)` succeeds — `_int_to_word` runs on this magnitude.
250 WholeDigits(BigInt),
251}
252
253/// Reproduce Python's `"." in str(number)` routing, including the `int(n)`
254/// `ValueError` for the point-less forms that are not integer literals.
255///
256/// * **Float** — repr is fixed-notation (always with a ".") for every finite
257/// value outside the scientific range, so the only no-point floats are the
258/// scientific ones (`|v| >= 1e16`, `0 < |v| < 1e-4`) and inf/nan — all of
259/// which make `int()` raise. `FloatValue::has_visible_point` is *not* used
260/// here: it reports `true` for tiny scientific floats (`1e-05`), whose repr
261/// has no point and must raise.
262/// * **Decimal** — `str(Decimal)` is reconstructed by `python_decimal_str`;
263/// a point routes to the decimal grammar, plain digits (canonical integral
264/// Decimals like "100") parse, and everything else ("1E+2", "1E+20") is the
265/// `int()` ValueError.
266fn route_by_str(v: &FloatValue) -> Result<StrRoute> {
267 match v {
268 FloatValue::Float { value, .. } => {
269 let a = value.abs();
270 if !a.is_finite() {
271 // str(float("inf")) == "inf", str(float("nan")) == "nan" —
272 // no ".", so int() raises. The "-" of "-inf" is stripped by
273 // the string-sign peel before int() ever sees it.
274 return Err(int_value_error(if a.is_nan() { "nan" } else { "inf" }));
275 }
276 if a >= 1e16 || (a > 0.0 && a < 1e-4) {
277 // repr picked exponent form: no "." in n -> int(n) raises.
278 return Err(int_value_error(&py_float_sci_repr(a)));
279 }
280 // Every other finite float reprs with a point ("5.0", "0.0001").
281 Ok(StrRoute::Point)
282 }
283 FloatValue::Decimal { value, .. } => {
284 // abs() first: Python strips the string sign before this test.
285 let s = python_decimal_str(&value.abs());
286 if s.contains('.') {
287 Ok(StrRoute::Point)
288 } else if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
289 Ok(StrRoute::WholeDigits(s.parse().expect("digits only")))
290 } else {
291 // "1E+2", "1E+20", ... — int() raises.
292 Err(int_value_error(&s))
293 }
294 }
295 }
296}
297
298pub struct LangFo {
299 /// `Num2Word_FO.CURRENCY_FORMS`.
300 ///
301 /// Built once in [`LangFo::new`] and stored, never per call: the binding
302 /// holds the converter in a `OnceLock`, so this table is constructed
303 /// exactly once per process.
304 ///
305 /// Each entry is a 2-tuple of 2-tuples, `((unit_sg, unit_pl), (sub_sg,
306 /// sub_pl))`. The arity is load-bearing: `to_currency` indexes `[1]` for
307 /// the plural and `to_cheque` takes `cr1[-1]`, so both forms must be
308 /// present and distinct.
309 currency_forms: HashMap<&'static str, CurrencyForms>,
310}
311
312impl LangFo {
313 pub fn new() -> Self {
314 // Insertion order is irrelevant to a HashMap; FALLBACK_CURRENCY
315 // captures the one place Python's ordering was observable.
316 let currency_forms: HashMap<&'static str, CurrencyForms> = [
317 ("DKK", &["króna", "krónur"][..], &["oyra", "oyru"][..]),
318 ("USD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
319 ("EUR", &["euro", "euros"][..], &["cent", "cents"][..]),
320 ]
321 .into_iter()
322 .map(|(k, u, s)| (k, CurrencyForms::new(u, s)))
323 .collect();
324 LangFo { currency_forms }
325 }
326
327 /// The `(left, right)` split at the head of `Num2Word_FO.to_currency`, for
328 /// a value already made non-negative.
329 ///
330 /// Python works on the *string*:
331 ///
332 /// ```python
333 /// parts = str(val).split(".")
334 /// left = int(parts[0]) if parts[0] else 0
335 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
336 /// ```
337 ///
338 /// Taking the first two fractional digits and right-padding to two is
339 /// exactly `trunc(frac * 100)` for any plain decimal string — `"5"` →
340 /// `"50"` → 50 is `trunc(0.5 * 100)`, `"345"` → `"34"` is
341 /// `trunc(0.345 * 100)` — so this computes the split arithmetically rather
342 /// than round-tripping the value back through `BigDecimal`'s `Display`.
343 /// Identical results, one less dependency on a formatting impl we do not
344 /// control. (`lang_eu.rs` ports the same idiom the same way.)
345 ///
346 /// A true `int` has no "." in `str(val)`, so `len(parts) > 1` is false and
347 /// `right` is 0 — which is also what `frac == 0` gives here. FO therefore
348 /// needs no `isinstance(val, int)` branch: bug 11 means the int/float
349 /// distinction is invisible in the output.
350 fn split_currency(val: &CurrencyValue) -> (BigInt, BigInt) {
351 match val {
352 CurrencyValue::Int(i) => (i.abs(), BigInt::zero()),
353 CurrencyValue::Decimal { value: d, .. } => {
354 let abs = d.abs();
355 // int(parts[0]) — truncation, and abs is non-negative, so this
356 // is floor as well.
357 let left = abs.with_scale(0).as_bigint_and_exponent().0;
358 let frac = &abs - BigDecimal::from(left.clone());
359 let right = (frac * BigDecimal::from(CENT_DIVISOR))
360 .with_scale(0)
361 .as_bigint_and_exponent()
362 .0;
363 (left, right)
364 }
365 }
366 }
367
368 /// Port of `Num2Word_FO._int_to_word`.
369 ///
370 /// Infallible: mirrors Python exactly, and Python cannot raise here. Every
371 /// `ONES`/`TENS` index is bounded by the enclosing range check (a value
372 /// `< 100` divided by 10 is `< 10`), so the `to_usize` casts below are
373 /// proven safe rather than assumed — the BigInt is only narrowed once the
374 /// branch has established it fits.
375 fn int_to_word(&self, number: &BigInt) -> String {
376 // Python: `return self.ones[0] if self.ones[0] else "zero"`.
377 // ONES[0] is "" → falsy → the fallback always wins. The condition is
378 // kept verbatim rather than folded to "zero" to document the dead arm.
379 if number.is_zero() {
380 return if ONES[0].is_empty() {
381 "zero".to_string()
382 } else {
383 ONES[0].to_string()
384 };
385 }
386
387 // Unreachable from `to_cardinal`, which strips the sign before calling
388 // in. Kept because Python has it: it would double the negword prefix.
389 if number.is_negative() {
390 return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
391 }
392
393 if *number < BigInt::from(10) {
394 // 1..=9; ONES[0] is unreachable (the is_zero arm caught it).
395 return ONES[number.to_usize().expect("< 10")].to_string();
396 }
397
398 if *number < BigInt::from(100) {
399 let (tens_val, ones_val) = number.div_mod_floor(&BigInt::from(10));
400 let tens_word = TENS[tens_val.to_usize().expect("< 10")];
401 if ones_val.is_zero() {
402 return tens_word.to_string();
403 }
404 return format!("{} {}", tens_word, ONES[ones_val.to_usize().expect("< 10")]);
405 }
406
407 if *number < BigInt::from(1000) {
408 let (hundreds_val, remainder) = number.div_mod_floor(&BigInt::from(100));
409 // Python indexes `self.ones` directly here rather than recursing,
410 // which is why 100 is "ein hundrað" and not "hundrað".
411 let mut result = format!("{} {}", ONES[hundreds_val.to_usize().expect("< 10")], HUNDRED);
412 if !remainder.is_zero() {
413 result.push(' ');
414 result.push_str(&self.int_to_word(&remainder));
415 }
416 return result;
417 }
418
419 if *number < BigInt::from(1_000_000) {
420 let (thousands_val, remainder) = number.div_mod_floor(&BigInt::from(1000));
421 let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
422 if !remainder.is_zero() {
423 result.push(' ');
424 result.push_str(&self.int_to_word(&remainder));
425 }
426 return result;
427 }
428
429 if *number < BigInt::from(1_000_000_000) {
430 let (millions_val, remainder) = number.div_mod_floor(&BigInt::from(1_000_000));
431 let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
432 if !remainder.is_zero() {
433 result.push(' ');
434 result.push_str(&self.int_to_word(&remainder));
435 }
436 return result;
437 }
438
439 // Python: `return str(number) # Fallback for very large numbers`.
440 // Digits, verbatim, no words and no exception. See bug 3.
441 number.to_string()
442 }
443}
444
445impl Default for LangFo {
446 fn default() -> Self {
447 Self::new()
448 }
449}
450
451impl Lang for LangFo {
452 /// This language's own `to_currency(currency=...)` default,
453 /// read from the live Python signature. Only 44 of 156 use EUR.
454 fn default_currency(&self) -> &str {
455 "DKK"
456 }
457
458 /// This language's own `to_currency(separator=...)` default,
459 /// read from the live Python signature. Base's is ",", but only
460 /// 36 of 149 languages actually use it — most default to " " or a
461 /// conjunction, so inheriting Base's comma silently corrupts them.
462 fn default_separator(&self) -> &str {
463 " "
464 }
465
466 fn negword(&self) -> &str {
467 NEGWORD
468 }
469
470 fn pointword(&self) -> &str {
471 "point"
472 }
473
474 /// Port of `Num2Word_FO.to_cardinal`, integer path only.
475 ///
476 /// Python stringifies the input, peels a leading "-" off the *string*, and
477 /// re-parses with `int()`; `str(int)` never contains ".", so integers
478 /// always take the `else` branch. Peeling the sign textually and re-parsing
479 /// is exactly `abs()` for every BigInt, so that is what we do.
480 ///
481 /// The trailing `.strip()` is what removes the space inside `negword`
482 /// ("minus ") from a bare-negword result; for the shapes reachable here it
483 /// only ever trims the seam, since `int_to_word` never returns padding.
484 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
485 let (ret, n) = if value.is_negative() {
486 (NEGWORD, value.abs())
487 } else {
488 ("", value.clone())
489 };
490 Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
491 }
492
493 /// Port of `Num2Word_FO.to_ordinal`: cardinal + a literal "-ti", with no
494 /// `verify_ordinal` call and no inspection of what came back. See bugs
495 /// 4, 5 and 6.
496 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
497 Ok(format!("{}-ti", self.to_cardinal(value)?))
498 }
499
500 /// Port of `Num2Word_FO.to_ordinal_num`: `str(number) + "."`. Note this
501 /// overrides `Num2Word_Base.to_ordinal_num` (which returns the value
502 /// untouched), and that it keeps the minus sign: -1 → "-1.".
503 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
504 Ok(format!("{}.", value))
505 }
506
507 /// Port of `Num2Word_FO.to_year`. Python overrides the base method but the
508 /// body is just `return self.to_cardinal(val)`; the `longval=True`
509 /// parameter is accepted and never read. So 1900 → "ein túsund níggju
510 /// hundrað" (not a century split) and -44 → "minus fjøruti fýra" (not
511 /// "fjøruti fýra f.Kr."). Stated explicitly rather than left to the trait
512 /// default, since Python does override it.
513 fn to_year(&self, value: &BigInt) -> Result<String> {
514 self.to_cardinal(value)
515 }
516
517 /// Port of the float/Decimal path of `Num2Word_FO.to_cardinal`.
518 ///
519 /// FO does **not** inherit `Num2Word_Base.to_cardinal_float`/`float2tuple`.
520 /// It overrides `to_cardinal(number)`, which operates on `str(number)`:
521 ///
522 /// ```python
523 /// n = str(number).strip()
524 /// if n.startswith("-"):
525 /// n = n[1:]; ret = self.negword
526 /// else:
527 /// ret = ""
528 /// if "." in n:
529 /// left, right = n.split(".", 1)
530 /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
531 /// for digit in right:
532 /// ret += self._int_to_word(int(digit)) + " "
533 /// return ret.strip()
534 /// ```
535 ///
536 /// The fractional part is read **digit by digit off the decimal string**,
537 /// so the `float2tuple` f64-artefact heuristic never applies here: `2.675`
538 /// becomes `str` `"2.675"` → `"6 7 5"` directly, with no `674.999…` to
539 /// rescue. Reproduce that by rebuilding the repr string, not by arithmetic
540 /// on the raw double (which *would* reintroduce the artefact).
541 ///
542 /// * **Float** — Rust's `{}` drops the `.0` on whole floats (`1.0` → "1"),
543 /// so the repr is reconstructed with `{:.precision$}` on the absolute
544 /// value. `precision` is the repr-derived fractional length (== Python's
545 /// `abs(Decimal(str(v)).as_tuple().exponent)`), and formatting the double
546 /// back to that many places recovers exactly the shortest-round-trip
547 /// digits.
548 /// * **Decimal** — reconstructed arithmetically (exact): `int(left)` is the
549 /// truncated integer part, and the fractional digits are
550 /// `(abs - left) * 10**precision` left-padded to `precision`. This keeps
551 /// trailing zeros the way `str(Decimal("1.10"))` does ("1.10" → "10").
552 ///
553 /// `precision_override` (the `precision=` kwarg, issue #580) is **inert**
554 /// for FO: `to_cardinal` takes no such parameter, so the kwarg is dropped
555 /// before it can matter. Verified live:
556 /// `num2words(0.5, lang="fo", precision=3)` == "zero point fimm".
557 fn to_cardinal_float(
558 &self,
559 value: &FloatValue,
560 _precision_override: Option<u32>,
561 ) -> Result<String> {
562 // Rebuild `n` (already sign-stripped) as "<left>.<right>" with exactly
563 // `precision` fractional digits — or no "." when precision is 0, which
564 // mirrors Python's `if "." in n` guard falling through to the integer
565 // branch. `neg` mirrors `str(number).startswith("-")`.
566 let (neg, left, right_str) = match value {
567 FloatValue::Float { value, precision } => {
568 // Sign *bit*, not `< 0.0`: repr(-0.0) is "-0.0", so Python's
569 // startswith("-") is true and FO answers "minus zero point
570 // zero". A `< 0.0` test would drop the minus.
571 let neg = value.is_sign_negative();
572 let prec = *precision as usize;
573 let s = format!("{:.p$}", value.abs(), p = prec);
574 match s.split_once('.') {
575 Some((l, r)) => (neg, l.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero()), r.to_string()),
576 None => (neg, s.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero()), String::new()),
577 }
578 }
579 FloatValue::Decimal { value, precision } => {
580 let neg = value.is_negative();
581 let abs = value.abs();
582 // int(left) — truncate the absolute value toward zero.
583 let left = abs.with_scale(0).as_bigint_and_exponent().0;
584 if *precision == 0 {
585 (neg, left, String::new())
586 } else {
587 let frac = &abs - BigDecimal::from(left.clone());
588 let scale = BigInt::from(10).pow(*precision);
589 let right = (frac * BigDecimal::from(scale))
590 .with_scale(0)
591 .as_bigint_and_exponent()
592 .0;
593 // Left-pad to `precision` digits (right is non-negative and
594 // < 10**precision, so this never truncates). Manual pad
595 // rather than a numeric fill so BigInt's Display is used
596 // verbatim.
597 let rs = right.to_string();
598 let pad = (*precision as usize).saturating_sub(rs.len());
599 (neg, left, format!("{}{}", "0".repeat(pad), rs))
600 }
601 }
602 };
603
604 // ret += _int_to_word(int(left)) [+ " point " + per-digit words];
605 // strip. `pointword` is used raw — FO applies no title() here.
606 let mut tokens: Vec<String> = vec![self.int_to_word(&left)];
607 if !right_str.is_empty() {
608 tokens.push(POINTWORD.to_string());
609 for ch in right_str.chars() {
610 // Each char is one decimal digit; int(digit) ∈ 0..=9.
611 let d = ch.to_digit(10).unwrap_or(0);
612 tokens.push(self.int_to_word(&BigInt::from(d)));
613 }
614 }
615 let joined = tokens.join(" ");
616 // negword ("minus ") is prepended raw, then the whole thing stripped —
617 // exactly Python's `ret = self.negword + ...; return ret.strip()`.
618 let result = if neg { format!("{}{}", NEGWORD, joined) } else { joined };
619 Ok(result.trim().to_string())
620 }
621
622 /// `to_cardinal(float/Decimal)` — the full entry, routing on
623 /// `"." in str(number)` rather than on whole-ness (Base's
624 /// `int(value) == value` test never runs; FO overrides `to_cardinal`
625 /// outright).
626 ///
627 /// A whole float therefore keeps its ".0" tail (`5.0` -> "fimm point
628 /// zero", `-0.0` -> "minus zero point zero"), a point-less integral
629 /// Decimal takes the integer grammar (`Decimal("100")` -> "ein hundrað"),
630 /// and a point-less non-integer form is Python's `int()` ValueError
631 /// (`1e+16`, `Decimal("1E+2")`).
632 ///
633 /// `precision_override` is threaded through untouched; FO's grammar never
634 /// reads a precision, so it is inert either way (see `to_cardinal_float`).
635 fn cardinal_float_entry(
636 &self,
637 value: &FloatValue,
638 precision_override: Option<u32>,
639 ) -> Result<String> {
640 match route_by_str(value)? {
641 StrRoute::Point => self.to_cardinal_float(value, precision_override),
642 StrRoute::WholeDigits(abs_int) => {
643 // `(ret + self._int_to_word(int(n))).strip()`, with the sign
644 // already peeled off the string into `ret = self.negword`.
645 let body = self.int_to_word(&abs_int);
646 if value.is_negative() {
647 Ok(format!("{}{}", NEGWORD, body).trim().to_string())
648 } else {
649 Ok(body)
650 }
651 }
652 }
653 }
654
655 /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-ti"` — the
656 /// same blind suffix as the integer path (bugs 4/5/6), so `5.0` ==
657 /// "fimm point zero-ti" and the ValueError of `1e+16` propagates
658 /// unchanged.
659 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
660 Ok(format!("{}-ti", self.cardinal_float_entry(value, None)?))
661 }
662
663 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. Purely textual —
664 /// nothing is parsed, so even the scientific forms succeed: `1e+16` ==
665 /// "1e+16." and `Decimal("1E+2")` == "1E+2.".
666 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
667 Ok(format!("{}.", repr_str))
668 }
669
670 // `year_float_entry` is deliberately NOT overridden: FO's `to_year` is
671 // `return self.to_cardinal(val)`, and the trait default routes through
672 // the overridden `cardinal_float_entry` above — so `to_year(5.0)` ==
673 // "fimm point zero" and `to_year(1e+16)` raises ValueError, as the
674 // corpus pins.
675
676 /// `converter.str_to_number` — Base's `Decimal(value)`, which FO does not
677 /// override. `Decimal("Infinity")`/`"NaN"` parse fine; the ValueError FO
678 /// shows happens *later*, inside its own `to_cardinal`, which is now
679 /// served natively by [`Lang::inf_result`] / [`Lang::nan_result`] rather
680 /// than by a Python fallback.
681 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
682 python_decimal_parse(s)
683 }
684
685 /// `Decimal('Infinity')` / `-Infinity` per mode. FO's `to_cardinal`
686 /// re-parses the sign-stripped `str(number)` with `int()`, so
687 /// `int("Infinity")` raises `ValueError` for cardinal/ordinal/year (the
688 /// leading "-" of "-Infinity" is peeled off the string first, so the
689 /// message names 'Infinity' either way). `to_ordinal_num` never parses —
690 /// it is `str(number) + "."` — so it succeeds: "Infinity." / "-Infinity.".
691 fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
692 match to {
693 "ordinal_num" => Ok(format!("{}Infinity.", if negative { "-" } else { "" })),
694 _ => Err(int_value_error("Infinity")),
695 }
696 }
697
698 /// `Decimal('NaN')` per mode. `int("NaN")` raises `ValueError` for
699 /// cardinal/ordinal/year; `to_ordinal_num` returns "NaN." unparsed.
700 fn nan_result(&self, to: &str) -> Result<String> {
701 match to {
702 "ordinal_num" => Ok("NaN.".to_string()),
703 _ => Err(int_value_error("NaN")),
704 }
705 }
706
707 // ---- currency ----------------------------------------------------
708
709 fn lang_name(&self) -> &str {
710 LANG_NAME
711 }
712
713 /// `CURRENCY_FORMS[code]`, **strictly** — `None` for anything else.
714 ///
715 /// Deliberately *not* the DKK-defaulting lookup FO's `to_currency` uses
716 /// (bug 8). This hook exists to serve the inherited
717 /// `Num2Word_Base.to_cheque`, which subscripts
718 /// `self.CURRENCY_FORMS[currency]` and turns the `KeyError` into
719 /// `NotImplementedError`. The fallback belongs to `to_currency` alone and
720 /// lives there; leaking it here would make `cheque:GBP` return
721 /// "... KRÓNUR" instead of raising, contradicting the corpus.
722 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
723 self.currency_forms.get(code)
724 }
725
726 /// Port of `Num2Word_FO.to_currency`.
727 ///
728 /// ```python
729 /// def to_currency(self, val, currency="DKK", cents=True,
730 /// separator=" ", adjective=False):
731 /// is_negative = False
732 /// if val < 0:
733 /// is_negative = True
734 /// val = abs(val)
735 ///
736 /// parts = str(val).split(".")
737 /// left = int(parts[0]) if parts[0] else 0
738 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
739 ///
740 /// cr1, cr2 = self.CURRENCY_FORMS.get(
741 /// currency, list(self.CURRENCY_FORMS.values())[0]
742 /// )
743 ///
744 /// left_str = self._int_to_word(left)
745 /// result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
746 ///
747 /// if cents and right:
748 /// cents_str = self._int_to_word(right)
749 /// result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
750 ///
751 /// if is_negative:
752 /// result = self.negword + result
753 ///
754 /// return result.strip()
755 /// ```
756 ///
757 /// A complete override: `Num2Word_Base.to_currency` is never called, so
758 /// none of `parse_currency_parts`, `pluralize`, `_cents_verbose`,
759 /// `_cents_terse`, `CURRENCY_PRECISION` or `CURRENCY_ADJECTIVES` is
760 /// reachable from here. Infallible — there is no `NotImplementedError`
761 /// path (bug 8) and no table index that can escape its range.
762 ///
763 /// Note the plural is selected by `left != 1` / `right != 1` directly
764 /// rather than by `pluralize`, which `Num2Word_FO` leaves abstract.
765 fn to_currency(
766 &self,
767 val: &CurrencyValue,
768 currency: &str,
769 cents: bool,
770 separator: Option<&str>,
771 _adjective: bool, // declared by Python, never read — bug 10.
772 ) -> Result<String> {
773 // Trait now hands us None when the caller omitted separator=;
774 // resolve it to this language's own default before the ported body.
775 let separator = separator.unwrap_or(self.default_separator());
776 let separator = if separator == SEPARATOR_UNSET {
777 SEPARATOR_DEFAULT
778 } else {
779 separator
780 };
781
782 // Python: `if val < 0: is_negative = True; val = abs(val)`. The abs is
783 // folded into split_currency, which every later step reads through.
784 let is_negative = val.is_negative();
785 let (left, right) = Self::split_currency(val);
786
787 // `.get(currency, list(CURRENCY_FORMS.values())[0])` — bug 8. Python
788 // evaluates the default eagerly on every call, but it cannot fail:
789 // DKK is always present.
790 let forms = match self.currency_forms.get(currency) {
791 Some(f) => f,
792 None => &self.currency_forms[FALLBACK_CURRENCY],
793 };
794
795 let one = BigInt::one();
796 let left_str = self.int_to_word(&left);
797 let mut result = format!(
798 "{} {}",
799 left_str,
800 if left != one { &forms.unit[1] } else { &forms.unit[0] }
801 );
802
803 // `if cents and right:` — a truthiness test on the cent count, not on
804 // the type of `val`. See bug 11.
805 if cents && !right.is_zero() {
806 let cents_str = self.int_to_word(&right);
807 result.push_str(separator);
808 result.push_str(¢s_str);
809 result.push(' ');
810 result.push_str(if right != one {
811 &forms.subunit[1]
812 } else {
813 &forms.subunit[0]
814 });
815 }
816
817 if is_negative {
818 // negword carries a trailing space ("minus "), so this is the seam
819 // the final strip() does *not* touch.
820 result = format!("{}{}", NEGWORD, result);
821 }
822
823 Ok(result.trim().to_string())
824 }
825}