num2words2_core/lang_fi.rs
1//! Port of `lang_FI.py` (Finnish), via `lang_EUR` → `Num2Word_Base`.
2//!
3//! Shape: **self-contained**, but only just. `Num2Word_FI` *is* an engine
4//! language in spirit — it defines `high/mid/low_numwords` and a `merge` — yet
5//! it overrides `splitnum`, `clean`, `merge` and `to_cardinal` with its own
6//! four-argument variants that thread an `Options` (ordinal/case/plural/prefer)
7//! through every step. Two things make the shared `base.rs` engine unusable:
8//!
9//! 1. FI's card *values* are not strings. They are Finnish **stems** paired
10//! with a Kotus inflection class — `("tuha", 46)` — or lists of such parts
11//! mixed with literal text — `[("y", 31), "toista"]`. The final surface
12//! form is only produced by [`inflect`], at merge time, once the required
13//! grammatical case is known. `base::Cards` maps `BigInt -> String`, so it
14//! cannot carry them.
15//! 2. FI keeps a **second** table, `self.ords`, parallel to `self.cards`.
16//! `splitnum` picks one or the other from `options.ordinal`, so ordinals
17//! are produced by re-running the whole engine over a different table
18//! rather than by post-processing the cardinal.
19//!
20//! So `cards()`/`maxval()`/`merge()` stay at their trait defaults here and this
21//! file carries private ports of `splitnum`/`clean`/`merge`/`inflect` plus its
22//! own [`Table`] type. The tree shape and the fold order are otherwise
23//! identical to `base.rs` — see the comments there.
24//!
25//! # Inherited behaviour
26//!
27//! * `Num2Word_EUR.setup` supplies `high_numwords` = `["cent"] +
28//! gen_high_numwords(units, tens, lows)` (100 Latin stems). EUR's
29//! `set_high_numwords` is **not** used — FI overrides it (see
30//! [`translate_high`]).
31//! * `Num2Word_Base.verify_ordinal` raises `TypeError` on negatives; FI's
32//! `to_ordinal` calls it, hence `to_ordinal(-1)` → `N2WError::Type`.
33//! * `Num2Word_Base.title` is a no-op because `is_title` is never set true.
34//! * `MAXVAL = 1000 * list(self.cards.keys())[0]` = 1000 · 10^600 = **10^603**.
35//! * The **float/Decimal cardinal path is inherited unchanged.** FI overrides
36//! only `to_cardinal` (the integer engine, above); it defines neither
37//! `to_cardinal_float` nor `float2tuple`, so `Num2Word_Base.to_cardinal_float`
38//! applies verbatim — which is exactly what the trait default
39//! [`Lang::to_cardinal_float`] → `floatpath::default_to_cardinal_float`
40//! ports. That default calls back into this file's `to_cardinal` for the
41//! integer part and each fractional digit, and reads `negword` ("miinus ")
42//! and `pointword` ("pilkku") from the impl below, so `0.5` →
43//! "nolla pilkku viisi", `-0.5` → "miinus nolla pilkku viisi", `2.675` →
44//! "kaksi pilkku kuusi seitsemän viisi" (the f64-artefact `.675` rescued by
45//! the `< 0.01` heuristic), and the trillion-scale Decimal
46//! `98746251323029.99` round-trips exactly. Since `is_title` is false,
47//! `title(pointword)` leaves "pilkku" untouched. Verified against the frozen
48//! corpus (`cardinal_dec` + float `cardinal` rows) and the live interpreter;
49//! no override is needed here and adding one would only risk divergence.
50//!
51//! # Faithfully reproduced Python bugs
52//!
53//! `Num2Word_FI.set_high_numwords` rewrites the Latin stems into Finnish. Its
54//! `octo`/`nove` arms mean to reach the "two from twenty" / "one from twenty"
55//! forms by looking 10 entries back in the *decade* order, but they are wrong
56//! in two ways, and both are preserved verbatim by [`translate_high`]:
57//!
58//! 1. The arms do `numword = high[i + -10]` and then slice `"octo"`/`"nove"`
59//! off **that** word — which generally does not start with `octo`/`nove` at
60//! all, so the slice removes 4 (resp. 5) arbitrary characters. Hence
61//! 10^480 is "duodegintiljoona" (from `high[10]` = "nonagint" → "duode" +
62//! "gint"), 10^474 is "undetogintiljoona" (from "novoctogint" → "unde" +
63//! "togint") and 10^468 is "duodektogintiljoona".
64//! 2. For `i < 10` Python's `high[i - 10]` wraps around to the **end** of the
65//! list — the short `lows` stems. So 10^594 = "undeiljoona" (`high[-9]` =
66//! "non", sliced to "") and 10^588 = "duodeiljoona" (`high[-8]` = "oct",
67//! sliced to ""), both missing their scale name entirely. [`wrap_index`]
68//! reproduces Python's negative indexing.
69//!
70//! Two more oddities that are *not* bugs but look like them, kept as-is:
71//! `"sexagint"` → `"seagint"` → **"seagintiljoona"** (10^360), because the
72//! blanket `replace("sex", "se")` also eats the `sex` of the decade name; and
73//! `"septrigint"` → **"septenrigintiljoona"** (10^222), because the `sept` arm
74//! re-prefixes a stem whose `sept` was already elided by EUR.
75//!
76//! Every high stem in this file is generated, not transcribed, and the
77//! generator was diffed against the live Python `cards`/`ords` tables for all
78//! 101 high entries.
79//!
80//! # Currency
81//!
82//! `Num2Word_FI` declares its **own** class-body `CURRENCY_FORMS`, which
83//! shadows `Num2Word_EUR.CURRENCY_FORMS`. So the `lang_EUR` trap — where
84//! `Num2Word_EN.__init__` mutates the shared EUR class dict in place and 16
85//! other classes silently read the English result — **does not reach FI**:
86//! `Num2Word_FI.CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS` is `False`, and
87//! the live table holds `("euro", "euroa")`, not EN's `("euro", "euros")`.
88//! This file therefore transcribes `lang_FI.py` literally, loops included.
89//!
90//! FI defines no `CURRENCY_PRECISION`, so `Num2Word_Base`'s empty dict applies
91//! and `.get(code, 100)` yields **100 for every code** — the trait default.
92//! Deliberately not overridden: JPY is a *2-decimal* currency here (its rare
93//! `sen` subunit is kept), so `default_to_currency`'s `divisor == 1` branch is
94//! unreachable, and KWD/BHD are simply absent from the table rather than being
95//! 3-decimal — they raise NotImplementedError.
96//!
97//! `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse` are
98//! inherited from `Num2Word_Base` unchanged; `pluralize` from `Num2Word_EUR`.
99//! Only `to_currency` is overridden — see [`LangFi::to_currency`].
100//!
101//! # Float/Decimal entry routing
102//!
103//! `to_cardinal` routes on `assert int(value) == value` — whole values take
104//! the integer engine, fractional ones `to_cardinal_float` — which is exactly
105//! the trait-default `cardinal_float_entry`, so that hook stays untouched. The
106//! other three modes need overrides:
107//!
108//! * `to_ordinal(float/Decimal)` runs `Num2Word_Base.verify_ordinal` first:
109//! fractional → `TypeError` (`errmsg_floatord`), negative → `TypeError`
110//! (`errmsg_negord`). `abs(-0.0) == -0.0` is true in Python, so **-0.0 is
111//! a valid ordinal input** ("nollas") — the override must not use the
112//! sign-bit-aware `FloatValue::is_negative`. Whole non-negative floats run
113//! the ordinal engine (`to_ordinal(5.0)` == "viides").
114//! * `to_ordinal_num(float/Decimal)` is `str(value) + "."` with no
115//! validation at all, so `to_ordinal_num(-1.5)` == "-1.5." — the override
116//! appends "." to the Python repr the binding computed.
117//! * `to_year(float/Decimal)`: `val < 0` (false for -0.0!) flips to abs +
118//! " ennen ajanlaskun alkua", then `to_cardinal(val).replace(" ", "")` —
119//! so fractional years glue the decimal grammar together:
120//! `to_year(0.5)` == "nollapilkkuviisi".
121//!
122//! String inputs need no hook: FI does not override `str_to_number`, so the
123//! default `Decimal(str)` parse feeds the same float entries.
124//!
125//! # Grammatical kwargs
126//!
127//! `to_cardinal`/`to_ordinal` accept `(case="nominative", plural=False,
128//! prefer=None)`, `to_ordinal_num` `(case, plural)` (both **unread** — no
129//! validation, still `str(value) + "."`), `to_year` `(suffix=None,
130//! longval=True)` (`longval` unread). Quirks reproduced by the `*_kw` hooks:
131//!
132//! * `case` goes through `NAME_TO_CASE[case]` — an unknown name (or a
133//! non-string like `None`) raises **KeyError**, and in `to_ordinal` that
134//! lookup happens *before* `verify_ordinal`, so
135//! `to_ordinal(-5, case="bogus")` is KeyError, not TypeError.
136//! * `case="accusative"` maps to ACC=12, which **no** KOTUS table defines,
137//! so it dies later with `KeyError: 12` inside `inflect`.
138//! * `to_cardinal(<fractional float>, case=<non-nominative>)` raises
139//! `NotImplementedError`; with nominative it falls to `to_cardinal_float`,
140//! silently ignoring `plural`/`prefer`.
141//! * `prefer=None` behaves as empty (`options.prefer or set()`).
142//! * `to_year(-n, suffix=s)` keeps the caller's suffix — the BC default only
143//! fills in when `suffix` is falsy (`suffix = suffix or ...` twice).
144//!
145//! # Cross-call state
146//!
147//! None. `Num2Word_FI` has no `_pending_*` handshake; `self.ords` is built once
148//! in `__init__` and only read afterwards.
149
150use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
151use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
152use crate::floatpath::FloatValue;
153use num_bigint::BigInt;
154use num_integer::Integer;
155use num_traits::{One, Signed, Zero};
156use std::collections::HashMap;
157
158// ---------------------------------------------------------------------------
159// Grammatical cases (module-level constants in lang_FI.py)
160// ---------------------------------------------------------------------------
161
162const NOM: u8 = 10; // nominative: the dictionary form
163const GEN: u8 = 11; // genitive: ~of/'s
164/// accusative: Python's `NAME_TO_CASE` maps "accusative" to 12, but **no**
165/// `KOTUS_TYPE` entry defines it, so `to_cardinal(n, case="accusative")` dies
166/// with `KeyError: 12` inside `inflect` — see [`kotus_suffixes`], which
167/// returns that `KeyError`. Reachable only through the `*_kw` hooks.
168const ACC: u8 = 12;
169const PTV: u8 = 13; // partitive: as an object
170const INE: u8 = 14; // inessive: in
171const ELA: u8 = 15; // elative: from/out of
172const ILL: u8 = 16; // illative: into
173const ADE: u8 = 17; // adessive: at/on
174const ABL: u8 = 18; // ablative: from
175const ALL: u8 = 19; // allative: to
176const ESS: u8 = 20; // essive: as (in the role of)
177const TRANSL: u8 = 21; // translative: to (the role of)
178const INSTRUC: u8 = 22; // instructive: with
179const ABE: u8 = 23; // abessive: without
180const COM: u8 = 24; // comitative: together with
181
182/// `NAME_TO_CASE[case]`. A miss is Python's `KeyError: 'bogus'` — the repr'd
183/// key is the whole message.
184fn name_to_case(name: &str) -> Result<u8> {
185 Ok(match name {
186 "nominative" => NOM,
187 "genitive" => GEN,
188 "accusative" => ACC,
189 "partitive" => PTV,
190 "inessive" => INE,
191 "elative" => ELA,
192 "illative" => ILL,
193 "adessive" => ADE,
194 "ablative" => ABL,
195 "allative" => ALL,
196 "essive" => ESS,
197 "translative" => TRANSL,
198 "instructive" => INSTRUC,
199 "abessive" => ABE,
200 "comitative" => COM,
201 other => return Err(N2WError::Key(format!("'{}'", other))),
202 })
203}
204
205/// `BACK_TO_FRONT`: vowel harmony. Insertion order matters only in that the
206/// replacements are applied in sequence; `ä`/`ö`/`y` are never re-matched, so
207/// they cannot cascade.
208const BACK_TO_FRONT: [(char, &str); 3] = [('a', "ä"), ('o', "ö"), ('u', "y")];
209
210// ---------------------------------------------------------------------------
211// KOTUS_TYPE — nominal inflection tables
212// ---------------------------------------------------------------------------
213
214/// One `CASE: (SINGULAR_SUFFIX+, PLURAL_SUFFIX+)` row.
215///
216/// Python stores either a plain `str` or a `tuple` of alternatives per slot.
217/// Both are modelled as a slice: length 1 is the plain string, length > 1 is
218/// the tuple of choices resolved against `options.prefer` in [`inflect`].
219type Row = (u8, &'static [&'static str], &'static [&'static str]);
220
221/// Kotus type 5/risti, no gradation.
222const K5: &[Row] = &[
223 (NOM, &["i"], &["it"]),
224 (GEN, &["in"], &["ien"]),
225 (PTV, &["ia"], &["eja"]),
226 (INE, &["issa"], &["eissa"]),
227 (ELA, &["ista"], &["eista"]),
228 (ILL, &["iin"], &["eihin"]),
229 (ADE, &["illa"], &["eilla"]),
230 (ABL, &["ilta"], &["eilta"]),
231 (ALL, &["ille"], &["eille"]),
232 (ESS, &["ina"], &["eina"]),
233 (TRANSL, &["iksi"], &["eiksi"]),
234 (INSTRUC, &["ein"], &["ein"]),
235 (ABE, &["itta"], &["eitta"]),
236 (COM, &["eine"], &["eine"]),
237];
238
239/// Kotus type 7/ovi, no gradation. Only reachable as the plural half of the
240/// derived type 108.
241const K7: &[Row] = &[
242 (NOM, &["i"], &["et"]),
243 (GEN, &["en"], &["ien"]),
244 (PTV, &["ea"], &["ia"]),
245 (INE, &["essa"], &["issa"]),
246 (ELA, &["esta"], &["ista"]),
247 (ILL, &["een"], &["iin"]),
248 (ADE, &["ella"], &["illa"]),
249 (ABL, &["elta"], &["ilta"]),
250 (ALL, &["elle"], &["ille"]),
251 (ESS, &["ena"], &["ina"]),
252 (TRANSL, &["eksi"], &["iksi"]),
253 (INSTRUC, &["in"], &["in"]),
254 (ABE, &["etta"], &["itta"]),
255 (COM, &["ine"], &["ine"]),
256];
257
258/// Kotus type 8/nalle, no gradation. Only reachable as the singular half of
259/// the derived type 108.
260const K8: &[Row] = &[
261 (NOM, &["e"], &["et"]),
262 (GEN, &["en"], &["ejen", "ein"]),
263 (PTV, &["ea"], &["eja"]),
264 (INE, &["essa"], &["eissa"]),
265 (ELA, &["esta"], &["eista"]),
266 (ILL, &["een"], &["eihin"]),
267 (ADE, &["ella"], &["eilla"]),
268 (ABL, &["elta"], &["eilta"]),
269 (ALL, &["elle"], &["eille"]),
270 (ESS, &["ena"], &["eina"]),
271 (TRANSL, &["eksi"], &["eiksi"]),
272 (INSTRUC, &["ein"], &["ein"]),
273 (ABE, &["etta"], &["eitta"]),
274 (COM, &["eine"], &["eine"]),
275];
276
277/// Kotus type 9/kala, t-d gradation (sata). Keyed 109 in Python.
278const K109: &[Row] = &[
279 (NOM, &["ta"], &["dat"]),
280 (GEN, &["dan"], &["tojen", "tain"]),
281 (PTV, &["taa"], &["toja"]),
282 (INE, &["dassa"], &["doissa"]),
283 (ELA, &["dasta"], &["doista"]),
284 (ILL, &["taan"], &["toihin"]),
285 (ADE, &["dalla"], &["doilla"]),
286 (ABL, &["dalta"], &["doilta"]),
287 (ALL, &["dalle"], &["doille"]),
288 (ESS, &["tana"], &["toina"]),
289 (TRANSL, &["daksi"], &["doiksi"]),
290 (INSTRUC, &["doin"], &["doin"]),
291 (ABE, &["datta"], &["doitta"]),
292 (COM, &["toine"], &["toine"]),
293];
294
295/// Kotus type 10/koira, no gradation.
296const K10: &[Row] = &[
297 (NOM, &["a"], &["at"]),
298 (GEN, &["an"], &["ien", "ain"]),
299 (PTV, &["aa"], &["ia"]),
300 (INE, &["assa"], &["issa"]),
301 (ELA, &["asta"], &["ista"]),
302 (ILL, &["aan"], &["iin"]),
303 (ADE, &["alla"], &["illa"]),
304 (ABL, &["alta"], &["ilta"]),
305 (ALL, &["alle"], &["ille"]),
306 (ESS, &["ana"], &["ina"]),
307 (TRANSL, &["aksi"], &["iksi"]),
308 (INSTRUC, &["in"], &["in"]),
309 (ABE, &["atta"], &["itta"]),
310 (COM, &["ine"], &["ine"]),
311];
312
313/// Kotus type 27/käsi, t-d gradation.
314const K27: &[Row] = &[
315 (NOM, &["si"], &["det"]),
316 (GEN, &["den"], &["sien", "tten"]),
317 (PTV, &["tta"], &["sia"]),
318 (INE, &["dessa"], &["sissa"]),
319 (ELA, &["desta"], &["sista"]),
320 (ILL, &["teen"], &["siin"]),
321 (ADE, &["della"], &["silla"]),
322 (ABL, &["delta"], &["silta"]),
323 (ALL, &["delle"], &["sille"]),
324 (ESS, &["tena"], &["sina"]),
325 (TRANSL, &["deksi"], &["siksi"]),
326 (INSTRUC, &["sin"], &["sin"]),
327 (ABE, &["detta"], &["sitta"]),
328 (COM, &["sine"], &["sine"]),
329];
330
331/// Kotus type 31/kaksi, t-d gradation.
332const K31: &[Row] = &[
333 (NOM, &["ksi"], &["hdet"]),
334 (GEN, &["hden"], &["ksien"]),
335 (PTV, &["hta"], &["ksia"]),
336 (INE, &["hdessa"], &["ksissa"]),
337 (ELA, &["hdesta"], &["ksista"]),
338 (ILL, &["hteen"], &["ksiin"]),
339 (ADE, &["hdella"], &["ksilla"]),
340 (ABL, &["hdelta"], &["ksilta"]),
341 (ALL, &["hdelle"], &["ksille"]),
342 (ESS, &["htena"], &["ksina"]),
343 (TRANSL, &["hdeksi"], &["ksiksi"]),
344 (INSTRUC, &["ksin"], &["ksin"]),
345 (ABE, &["hdetta"], &["ksitta"]),
346 (COM, &["ksine"], &["ksine"]),
347];
348
349/// Kotus type 32/sisar, no gradation. Only reachable as the base of the
350/// derived type 132.
351const K32: &[Row] = &[
352 (NOM, &[""], &["et"]),
353 (GEN, &["en"], &["ien", "ten"]),
354 (PTV, &["ta"], &["ia"]),
355 (INE, &["essa"], &["issa"]),
356 (ELA, &["esta"], &["ista"]),
357 (ILL, &["een"], &["iin"]),
358 (ADE, &["ella"], &["illa"]),
359 (ABL, &["elta"], &["ilta"]),
360 (ALL, &["elle"], &["ille"]),
361 (ESS, &["ena"], &["ina"]),
362 (TRANSL, &["eksi"], &["iksi"]),
363 (INSTRUC, &["in"], &["in"]),
364 (ABE, &["etta"], &["itta"]),
365 (COM, &["ine"], &["ine"]),
366];
367
368/// Kotus type 38/nainen, no gradation.
369const K38: &[Row] = &[
370 (NOM, &["nen"], &["set"]),
371 (GEN, &["sen"], &["sten", "sien"]),
372 (PTV, &["sta"], &["sia"]),
373 (INE, &["sessa"], &["sissa"]),
374 (ELA, &["sesta"], &["sista"]),
375 (ILL, &["seen"], &["siin"]),
376 (ADE, &["sella"], &["silla"]),
377 (ABL, &["selta"], &["silta"]),
378 (ALL, &["selle"], &["sille"]),
379 (ESS, &["sena"], &["sina"]),
380 (TRANSL, &["seksi"], &["siksi"]),
381 (INSTRUC, &["sin"], &["sin"]),
382 (ABE, &["setta"], &["sitta"]),
383 (COM, &["sine"], &["sine"]),
384];
385
386/// Kotus type 45/kahdeksas, nt-nn gradation. Carries every ordinal stem.
387const K45: &[Row] = &[
388 (NOM, &["s"], &["nnet"]),
389 (GEN, &["nnen"], &["nsien"]),
390 (PTV, &["tta"], &["nsia"]),
391 (INE, &["nnessa"], &["nsissa"]),
392 (ELA, &["nnesta"], &["nsista"]),
393 (ILL, &["nteen"], &["nsiin"]),
394 (ADE, &["nnella"], &["nsilla"]),
395 (ABL, &["nnelta"], &["nsilta"]),
396 (ALL, &["nnelle"], &["nsille"]),
397 (ESS, &["ntena"], &["nsina"]),
398 (TRANSL, &["nneksi"], &["nsiksi"]),
399 (INSTRUC, &["nsin"], &["nsin"]),
400 (ABE, &["nnetta"], &["nsitta"]),
401 (COM, &["nsine"], &["nsine"]),
402];
403
404/// Kotus type 46/tuhat, nt-nn gradation.
405const K46: &[Row] = &[
406 (NOM, &["t"], &["nnet"]),
407 (GEN, &["nnen"], &["nsien", "nten"]),
408 (PTV, &["tta"], &["nsia"]),
409 (INE, &["nnessa"], &["nsissa"]),
410 (ELA, &["nnesta"], &["nsista"]),
411 (ILL, &["nteen"], &["nsiin"]),
412 (ADE, &["nnella"], &["nsilla"]),
413 (ABL, &["nnelta"], &["nsilta"]),
414 (ALL, &["nnelle"], &["nsille"]),
415 (ESS, &["ntena"], &["nsina"]),
416 (TRANSL, &["nneksi"], &["nsiksi"]),
417 (INSTRUC, &["nsin"], &["nsin"]),
418 (ABE, &["nnetta"], &["nsitta"]),
419 (COM, &["nsine"], &["nsine"]),
420];
421
422/// kolme. Python builds this at import time as
423/// `{c: (KOTUS_TYPE[8][c][0], KOTUS_TYPE[7][c][1]) for c in KOTUS_TYPE[8]}` —
424/// singular column from type 8, plural column from type 7 — then overrides
425/// INSTRUC/ABE/COM. Written out literally here and verified cell-by-cell
426/// against the live `lang_FI.KOTUS_TYPE[108]`.
427///
428/// Note the GEN plural is type 7's plain `"ien"`, **not** type 8's
429/// `("ejen", "ein")` tuple, and that ACC (12) is absent because type 8 has no
430/// ACC key for the comprehension to iterate.
431const K108: &[Row] = &[
432 (NOM, &["e"], &["et"]),
433 (GEN, &["en"], &["ien"]),
434 (PTV, &["ea"], &["ia"]),
435 (INE, &["essa"], &["issa"]),
436 (ELA, &["esta"], &["ista"]),
437 (ILL, &["een"], &["iin"]),
438 (ADE, &["ella"], &["illa"]),
439 (ABL, &["elta"], &["ilta"]),
440 (ALL, &["elle"], &["ille"]),
441 (ESS, &["ena"], &["ina"]),
442 (TRANSL, &["eksi"], &["iksi"]),
443 // the three explicit overrides that follow the comprehension
444 (INSTRUC, &["en"], &["in"]),
445 (ABE, &["etta"], &["itta"]),
446 (COM, &["ine"], &["ine"]),
447];
448
449/// seitsemän, kahdeksan, yhdeksän. `KOTUS_TYPE[10].copy()` with NOM replaced
450/// by `("an", "at")`.
451const K110: &[Row] = &[
452 (NOM, &["an"], &["at"]),
453 (GEN, &["an"], &["ien", "ain"]),
454 (PTV, &["aa"], &["ia"]),
455 (INE, &["assa"], &["issa"]),
456 (ELA, &["asta"], &["ista"]),
457 (ILL, &["aan"], &["iin"]),
458 (ADE, &["alla"], &["illa"]),
459 (ABL, &["alta"], &["ilta"]),
460 (ALL, &["alle"], &["ille"]),
461 (ESS, &["ana"], &["ina"]),
462 (TRANSL, &["aksi"], &["iksi"]),
463 (INSTRUC, &["in"], &["in"]),
464 (ABE, &["atta"], &["itta"]),
465 (COM, &["ine"], &["ine"]),
466];
467
468/// kymmenen. `KOTUS_TYPE[32].copy()` with NOM replaced by `("en", "et")`.
469const K132: &[Row] = &[
470 (NOM, &["en"], &["et"]),
471 (GEN, &["en"], &["ien", "ten"]),
472 (PTV, &["ta"], &["ia"]),
473 (INE, &["essa"], &["issa"]),
474 (ELA, &["esta"], &["ista"]),
475 (ILL, &["een"], &["iin"]),
476 (ADE, &["ella"], &["illa"]),
477 (ABL, &["elta"], &["ilta"]),
478 (ALL, &["elle"], &["ille"]),
479 (ESS, &["ena"], &["ina"]),
480 (TRANSL, &["eksi"], &["iksi"]),
481 (INSTRUC, &["in"], &["in"]),
482 (ABE, &["etta"], &["itta"]),
483 (COM, &["ine"], &["ine"]),
484];
485
486/// `KOTUS_TYPE[kotus_type][case]`, reproducing both `KeyError`s.
487fn kotus_suffixes(kotus: u16, case: u8) -> Result<(&'static [&'static str], &'static [&'static str])> {
488 let rows: &'static [Row] = match kotus {
489 5 => K5,
490 7 => K7,
491 8 => K8,
492 10 => K10,
493 27 => K27,
494 31 => K31,
495 32 => K32,
496 38 => K38,
497 45 => K45,
498 46 => K46,
499 108 => K108,
500 109 => K109,
501 110 => K110,
502 132 => K132,
503 _ => return Err(N2WError::Key(format!("{}", kotus))),
504 };
505 for &(c, sg, pl) in rows {
506 if c == case {
507 return Ok((sg, pl));
508 }
509 }
510 // Only ACC (12) can land here, and only if a caller supplies
511 // case="accusative" — which this trait cannot. Python: `KeyError: 12`.
512 Err(N2WError::Key(format!("{}", case)))
513}
514
515// ---------------------------------------------------------------------------
516// Card / ord values
517// ---------------------------------------------------------------------------
518
519/// One element of a card value.
520///
521/// Python mixes three things in the same list: a bare `str` ("toista"), a
522/// 2-tuple `(stem, kotus_type)`, and a 3-tuple `(stem, kotus_type, case)` whose
523/// third slot overrides the case for singular nominative only.
524///
525/// [`Part::Text`] additionally carries merge output: `merge` replaces a value
526/// with the already-inflected surface string, and Python then re-feeds that
527/// string to `inflect`, where the `not isinstance(part, tuple)` arm passes it
528/// through untouched. So `inflect` is idempotent on merged text, and modelling
529/// merged text as a `Text` part preserves that exactly.
530#[derive(Debug, Clone)]
531enum Part {
532 Text(String),
533 Stem {
534 stem: String,
535 kotus: u16,
536 /// The 3-tuple's `part[2]`.
537 case_override: Option<u8>,
538 },
539}
540
541/// Python's card value: a single part or a list of parts. `inflect` normalises
542/// the former with `if not isinstance(parts, list): parts = [parts]`, so one
543/// uniform `Vec` covers both.
544type Value = Vec<Part>;
545
546fn text(s: impl Into<String>) -> Value {
547 vec![Part::Text(s.into())]
548}
549
550fn stem(s: &str, kotus: u16) -> Part {
551 Part::Stem { stem: s.to_string(), kotus, case_override: None }
552}
553
554/// The 3-tuple form, e.g. `("kymmen", 132, PTV)`.
555fn stem_case(s: &str, kotus: u16, case: u8) -> Part {
556 Part::Stem { stem: s.to_string(), kotus, case_override: Some(case) }
557}
558
559// ---------------------------------------------------------------------------
560// Options
561// ---------------------------------------------------------------------------
562
563/// Port of `lang_FI.Options`.
564///
565/// `case` is always [`NOM`] and `plural` always false when driven through the
566/// `Lang` trait, whose four modes take no grammatical arguments. `merge`'s
567/// multiplication arm still flips `case` to [`PTV`] internally, so both are
568/// live. `prefer` is always empty, which makes the tuple-of-alternatives
569/// resolution in [`inflect`] always fall to the first choice — exactly what
570/// Python does for `prefer=None`.
571#[derive(Debug, Clone)]
572struct Options {
573 ordinal: bool,
574 case: u8,
575 plural: bool,
576 prefer: Vec<String>,
577}
578
579impl Options {
580 /// `Options.variation(case=...)`: everything else is inherited.
581 fn with_case(&self, case: u8) -> Options {
582 Options { case, ..self.clone() }
583 }
584}
585
586// ---------------------------------------------------------------------------
587// inflect
588// ---------------------------------------------------------------------------
589
590/// Port of `lang_FI.inflect`.
591fn inflect(parts: &Value, options: &Options) -> Result<String> {
592 let mut out = String::new();
593 for part in parts {
594 let (stem, kotus, case_override) = match part {
595 // "part is plain text, concat and continue"
596 Part::Text(t) => {
597 out.push_str(t);
598 continue;
599 }
600 Part::Stem { stem, kotus, case_override } => (stem, *kotus, *case_override),
601 };
602
603 // "predefined case (kaksikymmentä, ...)": the 3-tuple overrides the
604 // singular nominative only.
605 let mut tmp_case = options.case;
606 if let Some(c) = case_override {
607 if options.case == NOM && !options.plural {
608 tmp_case = c;
609 }
610 }
611
612 let (sg, pl) = kotus_suffixes(kotus, tmp_case)?;
613 // Python indexes the (singular, plural) tuple with the bool directly.
614 let choices = if options.plural { pl } else { sg };
615
616 // "many choices, choose preferred or first": Python intersects the
617 // choice set with `options.prefer` and takes the single common element
618 // if and only if exactly one matches; otherwise choices[0].
619 let mut suffix = choices[0].to_string();
620 if choices.len() > 1 {
621 let common: Vec<&str> = choices
622 .iter()
623 .copied()
624 .filter(|c| options.prefer.iter().any(|p| p.as_str() == *c))
625 .collect();
626 if common.len() == 1 {
627 suffix = common[0].to_string();
628 }
629 }
630
631 // "apply vowel harmony" — gated on the *stem*'s characters, not the
632 // suffix's: only a stem with no back vowel at all fronts its suffix.
633 if !stem.chars().any(|ch| BACK_TO_FRONT.iter().any(|(b, _)| *b == ch)) {
634 for (back, front) in BACK_TO_FRONT {
635 suffix = suffix.replace(back, front);
636 }
637 }
638
639 out.push_str(stem);
640 out.push_str(&suffix);
641 }
642 Ok(out)
643}
644
645// ---------------------------------------------------------------------------
646// Table — the OrderedDict behind self.cards / self.ords
647// ---------------------------------------------------------------------------
648
649/// `BigInt -> Value`, in descending key order.
650///
651/// Python uses an `OrderedDict` and `splitnum` walks it in **insertion** order:
652/// high (10^600 … 10^12, 10^9, 10^6), then mid (1000, 100, 90 … 30), then low
653/// (20 … 0). That happens to be strictly descending — verified against the live
654/// table — so a descending `Vec` reproduces the iteration exactly and allows
655/// the same binary-search shortcut `base::Cards` uses.
656#[derive(Debug, Default)]
657struct Table {
658 entries: Vec<(BigInt, Value)>,
659}
660
661impl Table {
662 fn new() -> Self {
663 Table { entries: Vec::new() }
664 }
665
666 /// Append in Python's insertion order; callers must stay descending.
667 fn push(&mut self, key: BigInt, value: Value) {
668 self.entries.push((key, value));
669 }
670
671 fn get(&self, key: &BigInt) -> Option<&Value> {
672 self.entries
673 .binary_search_by(|(k, _)| key.cmp(k))
674 .ok()
675 .map(|i| &self.entries[i].1)
676 }
677
678 /// Entries from the first key `<= value`, descending — Python's
679 /// `for elem in elems: if elem > value: continue`.
680 fn iter_from(&self, value: &BigInt) -> impl Iterator<Item = &(BigInt, Value)> {
681 let start = self.entries.partition_point(|(k, _)| k > value);
682 self.entries[start..].iter()
683 }
684
685 fn highest(&self) -> Option<&BigInt> {
686 self.entries.first().map(|(k, _)| k)
687 }
688}
689
690// ---------------------------------------------------------------------------
691// splitnum tree
692// ---------------------------------------------------------------------------
693
694/// Same distinction as `base::Node`: `Leaf` is Python's `(value, num)` tuple,
695/// `List` a nested list. `clean` branches on it, so it must not be flattened.
696#[derive(Debug, Clone)]
697enum Node {
698 Leaf(Value, BigInt),
699 List(Vec<Node>),
700}
701
702impl Node {
703 fn is_leaf(&self) -> bool {
704 matches!(self, Node::Leaf(..))
705 }
706}
707
708// ---------------------------------------------------------------------------
709// High numword generation
710// ---------------------------------------------------------------------------
711
712/// Port of `Num2Word_EUR.gen_high_numwords` (duplicated rather than imported so
713/// this file stays self-contained and does not depend on another language's
714/// module).
715fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
716 let mut out: Vec<String> = Vec::new();
717 // Python: [u + t for t in tens for u in units] — tens is the outer loop.
718 for t in tens {
719 for u in units {
720 out.push(format!("{}{}", u, t));
721 }
722 }
723 out.reverse();
724
725 const REPLACEMENTS: &[(&str, &str)] = &[
726 ("novemn", "noven"),
727 ("novemo", "novo"),
728 ("octoo", "octo"),
729 ("quintd", "quind"),
730 ("quintn", "quin"),
731 ("quintq", "quinq"),
732 ("quints", "quins"),
733 ("quintt", "quint"),
734 ("quintv", "quinv"),
735 ("septenn", "septen"),
736 ("septent", "sept"),
737 ("sexn", "sen"),
738 ("sexs", "ses"),
739 ("tresd", "tred"),
740 ("tresn", "tren"),
741 ("tress", "tres"),
742 ("tresv", "trev"),
743 ("unno", "uno"),
744 ];
745 for (k, v) in REPLACEMENTS {
746 out = out.iter().map(|o| o.replace(k, v)).collect();
747 }
748 out.extend(lows.iter().map(|s| s.to_string()));
749 out
750}
751
752/// Python's `s[n:]` — by character, and empty (never a panic) when `s` is
753/// shorter than `n`.
754fn char_slice_from(s: &str, n: usize) -> String {
755 s.chars().skip(n).collect()
756}
757
758/// Python's `high[i + -10]`, including the negative-index wraparound that
759/// bug (2) in the module docs depends on.
760fn wrap_index(i: usize, len: usize) -> usize {
761 let idx = i as isize - 10;
762 (if idx < 0 { idx + len as isize } else { idx }) as usize
763}
764
765/// The stem-rewriting half of `Num2Word_FI.set_high_numwords`.
766///
767/// Reproduces the `octo`/`nove` look-back bugs verbatim; see the module docs.
768/// Diffed against the live Python table for all 100 stems.
769fn translate_high(high: &[String]) -> Vec<String> {
770 const REPLACEMENTS: [(&str, &str); 4] = [
771 ("qu", "kv"),
772 ("x", "ks"),
773 ("c", "k"),
774 ("kent", "sent"), // applied after c -> k to cent
775 ];
776
777 let mut translated: Vec<String> = Vec::with_capacity(high.len());
778 for (i, numword) in high.iter().enumerate() {
779 let mut numword: String = numword.clone();
780
781 // 1e6**6 is sekstiljoona but 1e6**16 is sedekiljoona.
782 if numword.starts_with("sex") && numword != "sext" {
783 // Blanket replace, not just the prefix: this is what turns
784 // "sexagint" into "seagint".
785 numword = numword.replace("sex", "se");
786 }
787 // 1e6**7 is septiljoona but 1e6**17 is septendekiljoona.
788 else if numword.starts_with("sept") && numword != "sept" {
789 numword = format!("septen{}", char_slice_from(&numword, "sept".len()));
790 }
791 // 1e6**8 is oktiljoona but 1e6**18 is duodevigintiljoona (2 from 20).
792 else if numword.starts_with("octo") {
793 // NB: indexes the *original* list, then slices "octo".len() off a
794 // word that generally does not start with "octo".
795 let w = &high[wrap_index(i, high.len())];
796 numword = format!("duode{}", char_slice_from(w, "octo".len()));
797 }
798 // 1e6**9 is noniljoona but 1e6**19 is undevigintiljoona (1 from 20).
799 else if numword.starts_with("nove") {
800 let w = &high[wrap_index(i, high.len())];
801 // len("nove") + 1 == 5 — one more than the prefix it claims to strip.
802 numword = format!("unde{}", char_slice_from(w, "nove".len() + 1));
803 }
804
805 // apply general replacements to all numwords
806 for (a, b) in REPLACEMENTS {
807 numword = numword.replace(a, b);
808 }
809 translated.push(numword);
810 }
811 translated
812}
813
814// ---------------------------------------------------------------------------
815// Currency tables
816// ---------------------------------------------------------------------------
817
818/// `Num2Word_FI.CURRENCY_FORMS`.
819///
820/// FI's own class-body dict — **not** the `Num2Word_EUR` one that
821/// `Num2Word_EN.__init__` mutates in place, because declaring the attribute on
822/// the subclass shadows it outright. See the module docs.
823///
824/// Built in the same three steps as the Python class body — the literal dict,
825/// then the `crowns` / `dollars` / `pounds` `for curr_code in ...` loops that
826/// write into it at class-definition time — so the entries line up one-for-one
827/// with `lang_FI.py`. Verified against the live table: 24 codes, every one a
828/// 2-form tuple on both sides.
829///
830/// (The loops also leave a stray `curr_code = "GBP"` class attribute behind,
831/// Python's for-loop variable outliving its scope. It is never read, so it has
832/// no counterpart here.)
833fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
834 // Module-level constants in lang_FI.py.
835 const GENERIC_CENTS: [&str; 2] = ["sentti", "senttiä"];
836 const GENERIC_CENTAVOS: [&str; 2] = ["centavo", "centavoa"];
837
838 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
839
840 // ---- the literal dict ----
841 m.insert("BRL", CurrencyForms::new(&["real", "realia"], &GENERIC_CENTAVOS));
842 m.insert("CHF", CurrencyForms::new(&["frangi", "frangia"], &["rappen", "rappenia"]));
843 m.insert("CNY", CurrencyForms::new(&["juan", "juania"], &["fen", "feniä"]));
844 m.insert("EUR", CurrencyForms::new(&["euro", "euroa"], &GENERIC_CENTS));
845 // historical
846 m.insert("FIM", CurrencyForms::new(&["markka", "markkaa"], &["penni", "penniä"]));
847 m.insert("INR", CurrencyForms::new(&["rupia", "rupiaa"], &["paisa", "paisaa"]));
848 // rare subunit
849 m.insert("JPY", CurrencyForms::new(&["jeni", "jeniä"], &["sen", "seniä"]));
850 // rare subunit
851 m.insert("KRW", CurrencyForms::new(&["won", "wonia"], &["jeon", "jeonia"]));
852 // rare subunit
853 m.insert("KPW", CurrencyForms::new(&["won", "wonia"], &["chon", "chonia"]));
854 m.insert("MXN", CurrencyForms::new(&["peso", "pesoa"], &GENERIC_CENTAVOS));
855 m.insert("RUB", CurrencyForms::new(&["rupla", "ruplaa"], &["kopeekka", "kopeekkaa"]));
856 m.insert("TRY", CurrencyForms::new(&["liira", "liiraa"], &["kuruş", "kuruşia"]));
857 m.insert("ZAR", CurrencyForms::new(&["randi", "randia"], &GENERIC_CENTS));
858
859 // ---- crowns ----
860 for code in ["DKK", "ISK", "NOK", "SEK"] {
861 m.insert(code, CurrencyForms::new(&["kruunu", "kruunua"], &["äyri", "äyriä"]));
862 }
863 // ---- dollars ----
864 for code in ["AUD", "CAD", "HKD", "NZD", "SGD", "USD"] {
865 m.insert(code, CurrencyForms::new(&["dollari", "dollaria"], &GENERIC_CENTS));
866 }
867 // ---- pounds ----
868 // Python loops here too (`for curr_code in ("GBP",)`), but over a 1-tuple;
869 // written as a plain insert since the loop carries no extra data.
870 m.insert("GBP", CurrencyForms::new(&["punta", "puntaa"], &["penny", "pennyä"]));
871
872 m
873}
874
875/// `Num2Word_FI.CURRENCY_ADJECTIVES`. Shadows EUR's dict, same as the forms.
876///
877/// Only ever consulted on the float path: FI's `to_currency` int branch never
878/// calls `prefix_currency` — see [`LangFi::to_currency`].
879fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
880 [
881 ("AUD", "Australian"),
882 ("BRL", "Brasilian"),
883 ("CAD", "Kanadan"),
884 ("CHF", "Sveitsin"),
885 ("DKK", "Tanskan"),
886 ("FIM", "Suomen"), // historical
887 ("GBP", "Englannin"),
888 ("HKD", "Hongkongin"),
889 ("INR", "Intian"),
890 ("ISK", "Islannin"),
891 ("KRW", "Etelä-Korean"),
892 ("KPW", "Pohjois-Korean"),
893 ("MXN", "Meksikon"),
894 ("NOK", "Norjan"),
895 ("NZD", "Uuden-Seelannin"),
896 ("RUB", "Venäjän"),
897 ("SEK", "Ruotsin"),
898 ("SGD", "Singaporen"),
899 ("TRY", "Turkin"),
900 ("USD", "Yhdysvaltain"),
901 ("ZAR", "Etelä-Afrikan"),
902 ]
903 .into_iter()
904 .collect()
905}
906
907// ---------------------------------------------------------------------------
908// The language
909// ---------------------------------------------------------------------------
910
911pub struct LangFi {
912 cards: Table,
913 ords: Table,
914 maxval: BigInt,
915 exclude_title: Vec<String>,
916 currency_forms: HashMap<&'static str, CurrencyForms>,
917 currency_adjectives: HashMap<&'static str, &'static str>,
918}
919
920impl Default for LangFi {
921 fn default() -> Self {
922 Self::new()
923 }
924}
925
926impl LangFi {
927 pub fn new() -> Self {
928 // ---- Num2Word_EUR.setup ----
929 let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
930 let units = [
931 "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
932 ];
933 let tens = [
934 "dec",
935 "vigint",
936 "trigint",
937 "quadragint",
938 "quinquagint",
939 "sexagint",
940 "septuagint",
941 "octogint",
942 "nonagint",
943 ];
944 let mut high = vec!["cent".to_string()];
945 high.extend(gen_high_numwords(&units, &tens, &lows));
946
947 let mut cards = Table::new();
948 let mut ords = Table::new();
949
950 // ---- Num2Word_FI.set_high_numwords ----
951 let translated = translate_high(&high);
952 let ten = BigInt::from(10u8);
953 // max = 6 * len(translated); zip(translated, range(max, 0, -6)).
954 // Both sides are 100 long, so every stem is consumed and n ends at 6.
955 let mut n: i64 = 6 * translated.len() as i64;
956 for word in translated.iter() {
957 if n <= 0 {
958 break; // range(max, 0, -6) is exhausted (unreachable: lengths match)
959 }
960 if n == 6 {
961 // "irregularity considering short scale and long scale" — 10^9
962 // is inserted *before* 10^6, keeping the table descending.
963 cards.push(ten.pow(9), vec![stem("miljard", 5)]);
964 ords.push(ten.pow(9), vec![stem("miljardi", 45)]);
965 }
966 cards.push(ten.pow(n as u32), vec![stem(&format!("{}iljoon", word), 10)]);
967 ords.push(ten.pow(n as u32), vec![stem(&format!("{}iljoona", word), 45)]);
968 n -= 6;
969 }
970
971 // ---- Num2Word_FI.setup: mid_numwords / mid_ords ----
972 cards.push(BigInt::from(1000), vec![stem("tuha", 46)]);
973 cards.push(BigInt::from(100), vec![stem("sa", 109)]);
974 cards.push(BigInt::from(90), vec![stem("yhdeks", 110), stem_case("kymmen", 132, PTV)]);
975 cards.push(BigInt::from(80), vec![stem("kahdeks", 110), stem_case("kymmen", 132, PTV)]);
976 cards.push(BigInt::from(70), vec![stem("seitsem", 110), stem_case("kymmen", 132, PTV)]);
977 cards.push(BigInt::from(60), vec![stem("kuu", 27), stem_case("kymmen", 132, PTV)]);
978 cards.push(BigInt::from(50), vec![stem("vii", 27), stem_case("kymmen", 132, PTV)]);
979 cards.push(BigInt::from(40), vec![stem("nelj", 10), stem_case("kymmen", 132, PTV)]);
980 cards.push(BigInt::from(30), vec![stem("kolm", 108), stem_case("kymmen", 132, PTV)]);
981
982 ords.push(BigInt::from(1000), vec![stem("tuhanne", 45)]);
983 ords.push(BigInt::from(100), vec![stem("sada", 45)]);
984 ords.push(BigInt::from(90), vec![stem("yhdeksä", 45), stem("kymmene", 45)]);
985 ords.push(BigInt::from(80), vec![stem("kahdeksa", 45), stem("kymmene", 45)]);
986 ords.push(BigInt::from(70), vec![stem("seitsemä", 45), stem("kymmene", 45)]);
987 ords.push(BigInt::from(60), vec![stem("kuude", 45), stem("kymmene", 45)]);
988 ords.push(BigInt::from(50), vec![stem("viide", 45), stem("kymmene", 45)]);
989 ords.push(BigInt::from(40), vec![stem("neljä", 45), stem("kymmene", 45)]);
990 ords.push(BigInt::from(30), vec![stem("kolma", 45), stem("kymmene", 45)]);
991
992 // ---- Num2Word_FI.setup: low_numwords / low_ords ----
993 cards.push(BigInt::from(20), vec![stem("ka", 31), stem_case("kymmen", 132, PTV)]);
994 cards.push(BigInt::from(19), vec![stem("yhdeks", 110), Part::Text("toista".into())]);
995 cards.push(BigInt::from(18), vec![stem("kahdeks", 110), Part::Text("toista".into())]);
996 cards.push(BigInt::from(17), vec![stem("seitsem", 110), Part::Text("toista".into())]);
997 cards.push(BigInt::from(16), vec![stem("kuu", 27), Part::Text("toista".into())]);
998 cards.push(BigInt::from(15), vec![stem("vii", 27), Part::Text("toista".into())]);
999 cards.push(BigInt::from(14), vec![stem("nelj", 10), Part::Text("toista".into())]);
1000 cards.push(BigInt::from(13), vec![stem("kolm", 108), Part::Text("toista".into())]);
1001 cards.push(BigInt::from(12), vec![stem("ka", 31), Part::Text("toista".into())]);
1002 cards.push(BigInt::from(11), vec![stem("y", 31), Part::Text("toista".into())]);
1003 cards.push(BigInt::from(10), vec![stem("kymmen", 132)]);
1004 cards.push(BigInt::from(9), vec![stem("yhdeks", 110)]);
1005 cards.push(BigInt::from(8), vec![stem("kahdeks", 110)]);
1006 cards.push(BigInt::from(7), vec![stem("seitsem", 110)]);
1007 cards.push(BigInt::from(6), vec![stem("kuu", 27)]);
1008 cards.push(BigInt::from(5), vec![stem("vii", 27)]);
1009 cards.push(BigInt::from(4), vec![stem("nelj", 10)]);
1010 cards.push(BigInt::from(3), vec![stem("kolm", 108)]);
1011 cards.push(BigInt::from(2), vec![stem("ka", 31)]);
1012 cards.push(BigInt::from(1), vec![stem("y", 31)]);
1013 cards.push(BigInt::zero(), vec![stem("noll", 10)]);
1014
1015 ords.push(BigInt::from(20), vec![stem("kahde", 45), stem("kymmene", 45)]);
1016 ords.push(BigInt::from(19), vec![stem("yhdeksä", 45), Part::Text("toista".into())]);
1017 ords.push(BigInt::from(18), vec![stem("kahdeksa", 45), Part::Text("toista".into())]);
1018 ords.push(BigInt::from(17), vec![stem("seitsemä", 45), Part::Text("toista".into())]);
1019 ords.push(BigInt::from(16), vec![stem("kuude", 45), Part::Text("toista".into())]);
1020 ords.push(BigInt::from(15), vec![stem("viide", 45), Part::Text("toista".into())]);
1021 ords.push(BigInt::from(14), vec![stem("neljä", 45), Part::Text("toista".into())]);
1022 ords.push(BigInt::from(13), vec![stem("kolma", 45), Part::Text("toista".into())]);
1023 ords.push(BigInt::from(12), vec![stem("kahde", 45), Part::Text("toista".into())]);
1024 ords.push(BigInt::from(11), vec![stem("yhde", 45), Part::Text("toista".into())]);
1025 ords.push(BigInt::from(10), vec![stem("kymmene", 45)]);
1026 ords.push(BigInt::from(9), vec![stem("yhdeksä", 45)]);
1027 ords.push(BigInt::from(8), vec![stem("kahdeksa", 45)]);
1028 ords.push(BigInt::from(7), vec![stem("seitsemä", 45)]);
1029 ords.push(BigInt::from(6), vec![stem("kuude", 45)]);
1030 ords.push(BigInt::from(5), vec![stem("viide", 45)]);
1031 ords.push(BigInt::from(4), vec![stem("neljä", 45)]);
1032 ords.push(BigInt::from(3), vec![stem("kolma", 45)]);
1033 ords.push(BigInt::from(2), vec![stem("toi", 38)]);
1034 ords.push(BigInt::from(1), vec![stem("ensimmäi", 38)]);
1035 ords.push(BigInt::zero(), vec![stem("nolla", 45)]);
1036
1037 // MAXVAL = 1000 * list(self.cards.keys())[0] = 1000 * 10^600 = 10^603.
1038 // Computed from `cards` only, but `to_ordinal` checks against it too.
1039 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
1040
1041 LangFi {
1042 cards,
1043 ords,
1044 maxval,
1045 exclude_title: vec!["pilkku".into(), "miinus".into()],
1046 // Built once here, never per call: `to_currency` only ever reads
1047 // these, and rebuilding them on each call is what made an earlier
1048 // revision of this port slower than the Python it replaces.
1049 currency_forms: build_currency_forms(),
1050 currency_adjectives: build_currency_adjectives(),
1051 }
1052 }
1053
1054 /// Port of `Num2Word_FI.splitnum`.
1055 ///
1056 /// `None` is Python falling off the loop and implicitly returning `None`
1057 /// (value above every card) — impossible after the MAXVAL guard.
1058 fn splitnum(&self, value: &BigInt, options: &Options) -> Result<Option<Vec<Node>>> {
1059 let elems = if options.ordinal { &self.ords } else { &self.cards };
1060
1061 for (elem, word) in elems.iter_from(value) {
1062 let mut out: Vec<Node> = Vec::new();
1063 let (div, mod_) = if value.is_zero() {
1064 (BigInt::one(), BigInt::zero())
1065 } else {
1066 value.div_mod_floor(elem)
1067 };
1068
1069 if div.is_one() {
1070 let one = BigInt::one();
1071 // elems[1]; present in both tables.
1072 let v = elems.get(&one).cloned().ok_or_else(|| N2WError::Key("1".into()))?;
1073 out.push(Node::Leaf(v, one));
1074 } else {
1075 if &div == value {
1076 // "The system tallies, eg Roman Numerals": Python would do
1077 // `div * elems[elem]`, repeating a *tuple/list* of stems —
1078 // which `inflect` could not read back. Unreachable for FI:
1079 // reaching here needs elem == 1 with value >= 2, but cards
1080 // 0..=20 are all present, so the first card <= value is
1081 // always >= 2 once value >= 2.
1082 return Err(N2WError::Type(
1083 "lang_FI splitnum tally branch is unreachable".into(),
1084 ));
1085 }
1086 match self.splitnum(&div, options)? {
1087 Some(sub) => out.push(Node::List(sub)),
1088 None => return Ok(None),
1089 }
1090 }
1091
1092 out.push(Node::Leaf(word.clone(), elem.clone()));
1093
1094 if !mod_.is_zero() {
1095 match self.splitnum(&mod_, options)? {
1096 Some(sub) => out.push(Node::List(sub)),
1097 None => return Ok(None),
1098 }
1099 }
1100
1101 return Ok(Some(out));
1102 }
1103 Ok(None)
1104 }
1105
1106 /// Port of `Num2Word_FI.merge`.
1107 ///
1108 /// <http://www.kielitoimistonohjepankki.fi/ohje/49>
1109 ///
1110 /// `None` mirrors Python falling off the end of the if/elif chain when
1111 /// `lnum == rnum` (and `lnum != 1`) — there is no `else`, so it returns
1112 /// `None` and `to_cardinal`'s `words, num = self.clean(...)` then dies with
1113 /// `TypeError: cannot unpack non-sequence NoneType`. Unreachable with FI's
1114 /// card set: `div == elem` would require `elem**2 <= value < elem**2+elem`
1115 /// while `elem` is also the largest card `<= value`, which no gap in
1116 /// {0..20, 30..90 by 10, 100, 1000, 10^6, 10^9, 10^12 …} allows.
1117 ///
1118 /// Named `merge_parts`, not `merge`, purely to keep it clear of the
1119 /// `Lang::merge` trait method it would otherwise shadow — that one takes
1120 /// `&str` and is never reached for FI.
1121 fn merge_parts(
1122 &self,
1123 l: (&Value, &BigInt),
1124 r: (&Value, &BigInt),
1125 options: &Options,
1126 ) -> Result<Option<(Value, BigInt)>> {
1127 let (ltext, lnum) = l;
1128 let (rtext, rnum) = r;
1129
1130 let one = BigInt::one();
1131 let two = BigInt::from(2);
1132 let thousand = BigInt::from(1000);
1133
1134 // "ignore lpair if lnum is 1"
1135 if *lnum == one {
1136 let rt = inflect(rtext, options)?;
1137 return Ok(Some((text(rt), rnum.clone())));
1138 }
1139 // "rnum is added to lnum"
1140 if lnum > rnum {
1141 let lt = inflect(ltext, options)?;
1142 let rt = inflect(rtext, options)?;
1143 // "separate groups with space"
1144 let fmt = if *lnum >= thousand { " " } else { "" };
1145 return Ok(Some((text(format!("{}{}{}", lt, fmt, rt)), lnum + rnum)));
1146 }
1147 // "rnum is multiplied by lnum"
1148 if lnum < rnum {
1149 let (lval, rt): (Value, String) = if options.ordinal {
1150 // "kahdessadas, not toinensadas"
1151 let lval = if *lnum == two { vec![stem("kahde", 45)] } else { ltext.clone() };
1152 (lval, inflect(rtext, options)?)
1153 } else {
1154 // "kaksituhatta but kahdettuhannet"
1155 let mut rcase = options.case;
1156 if options.case == NOM && !options.plural {
1157 rcase = PTV;
1158 }
1159 (ltext.clone(), inflect(rtext, &options.with_case(rcase))?)
1160 };
1161 let lt = inflect(&lval, options)?;
1162 // NB: the multiplication arm never takes the space `fmt`; only the
1163 // addition arm above rebinds it.
1164 return Ok(Some((text(format!("{}{}", lt, rt)), lnum * rnum)));
1165 }
1166
1167 Ok(None)
1168 }
1169
1170 /// Port of `Num2Word_FI.clean` (identical in structure to `base.clean`,
1171 /// including the quirk where the tail `val[2:]` is appended as a *nested
1172 /// list* rather than spliced).
1173 fn clean(&self, val: Vec<Node>, options: &Options) -> Result<Node> {
1174 let mut val = val;
1175 while val.len() != 1 {
1176 let mut out: Vec<Node> = Vec::new();
1177 if val.len() >= 2 && val[0].is_leaf() && val[1].is_leaf() {
1178 let (lt, ln) = match &val[0] {
1179 Node::Leaf(t, n) => (t.clone(), n.clone()),
1180 _ => unreachable!(),
1181 };
1182 let (rt, rn) = match &val[1] {
1183 Node::Leaf(t, n) => (t.clone(), n.clone()),
1184 _ => unreachable!(),
1185 };
1186 match self.merge_parts((<, &ln), (&rt, &rn), options)? {
1187 Some((mt, mn)) => out.push(Node::Leaf(mt, mn)),
1188 None => {
1189 return Err(N2WError::Type(
1190 "cannot unpack non-sequence NoneType object".into(),
1191 ))
1192 }
1193 }
1194 if val.len() > 2 {
1195 out.push(Node::List(val[2..].to_vec()));
1196 }
1197 } else {
1198 for elem in val.into_iter() {
1199 match elem {
1200 Node::List(inner) => {
1201 if inner.len() == 1 {
1202 out.push(inner.into_iter().next().unwrap());
1203 } else {
1204 out.push(self.clean(inner, options)?);
1205 }
1206 }
1207 leaf => out.push(leaf),
1208 }
1209 }
1210 }
1211 val = out;
1212 }
1213 Ok(val.into_iter().next().unwrap())
1214 }
1215
1216 /// Drives splitnum + clean for an already-non-negative, in-range value.
1217 fn convert(&self, value: &BigInt, options: &Options) -> Result<String> {
1218 let tree = self.splitnum(value, options)?.ok_or_else(|| {
1219 N2WError::Overflow(format!("abs({}) must be less than {}.", value, self.maxval))
1220 })?;
1221 match self.clean(tree, options)? {
1222 Node::Leaf(v, _) => inflect(&v, options),
1223 Node::List(_) => Err(N2WError::Type("clean did not reduce".into())),
1224 }
1225 }
1226
1227 /// Port of `Num2Word_Base.verify_ordinal`. The float arm cannot fire on a
1228 /// `BigInt`; the negative arm is the one the corpus exercises.
1229 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
1230 if value.is_negative() {
1231 return Err(N2WError::Type(format!(
1232 "Cannot treat negative num {} as ordinal.",
1233 value
1234 )));
1235 }
1236 Ok(())
1237 }
1238
1239 /// The body of `Num2Word_FI.to_cardinal` past the `NAME_TO_CASE` lookup
1240 /// and the int/float routing, for an integral value with an arbitrary
1241 /// `Options`. `to_cardinal` and `to_cardinal_kw` both land here.
1242 fn to_cardinal_opts(&self, value: &BigInt, options: &Options) -> Result<String> {
1243 // `out = self.negword` — raw, see `to_cardinal`'s doc.
1244 let mut out = String::new();
1245 let mut v = value.clone();
1246 if v.is_negative() {
1247 v = v.abs();
1248 out.push_str(self.negword());
1249 }
1250
1251 if v >= self.maxval {
1252 return Err(N2WError::Overflow(format!(
1253 "abs({}) must be less than {}.",
1254 v, self.maxval
1255 )));
1256 }
1257
1258 let words = self.convert(&v, options)?;
1259 Ok(self.title(&format!("{}{}", out, words)))
1260 }
1261
1262 /// The body of `Num2Word_FI.to_ordinal` past the `NAME_TO_CASE` lookup.
1263 /// Note Python resolves `case` *before* `verify_ordinal`, so callers must
1264 /// build `Options` (and surface its KeyError) first.
1265 fn to_ordinal_opts(&self, value: &BigInt, options: &Options) -> Result<String> {
1266 self.verify_ordinal(value)?;
1267 if value >= &self.maxval {
1268 return Err(N2WError::Overflow(format!(
1269 "abs({}) must be less than {}.",
1270 value, self.maxval
1271 )));
1272 }
1273
1274 let words = self.convert(value, options)?;
1275 Ok(self.title(&words))
1276 }
1277
1278 /// Builds `Options` from a kwargs bag, in Python's evaluation order:
1279 /// `case = NAME_TO_CASE[case]` first (KeyError on unknown or non-string
1280 /// keys), then `plural`/`prefer` verbatim.
1281 ///
1282 /// Values our port cannot model faithfully (e.g. `plural=2`, whose Python
1283 /// behaviour mixes tuple-indexing with truthiness) return NotImplemented
1284 /// so the dispatcher falls back to Python. The corpus only exercises
1285 /// bools, case names and string lists.
1286 fn options_from_kwargs(&self, kw: &Kwargs, ordinal: bool) -> Result<Options> {
1287 let case = match kw.get("case") {
1288 None => NOM,
1289 Some(KwVal::Str(s)) => name_to_case(s)?,
1290 // NAME_TO_CASE[None] / NAME_TO_CASE[5] / ... — a dict miss on the
1291 // non-string key. Only the exception type is oracle-checked.
1292 Some(other) => return Err(N2WError::Key(format!("{:?}", other))),
1293 };
1294 let plural = match kw.get("plural") {
1295 None => false,
1296 Some(KwVal::Bool(b)) => *b,
1297 // Python indexes `(sg, pl)[plural]`, so ints 0/1 behave as bools.
1298 Some(KwVal::Int(0)) => false,
1299 Some(KwVal::Int(1)) => true,
1300 Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1301 };
1302 let prefer = match kw.get("prefer") {
1303 // `options.prefer or set()` — None is the empty preference set.
1304 None | Some(KwVal::None) => Vec::new(),
1305 Some(KwVal::List(l)) => l.clone(),
1306 Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1307 };
1308 Ok(Options { ordinal, case, plural, prefer })
1309 }
1310}
1311
1312/// Best-effort Python `str()` of a float/Decimal for TypeError messages
1313/// (`errmsg_floatord` / `errmsg_negord`). The corpus records only the
1314/// exception *type*; the message follows base.py's format strings.
1315fn float_msg_repr(value: &FloatValue) -> String {
1316 match value {
1317 FloatValue::Float { value: f, .. } => {
1318 if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e16 {
1319 format!("{:.1}", f)
1320 } else {
1321 format!("{}", f)
1322 }
1323 }
1324 FloatValue::Decimal { value: d, .. } => crate::strnum::python_decimal_str(d),
1325 }
1326}
1327
1328impl Lang for LangFi {
1329
1330 fn python_maxval(&self) -> Option<num_bigint::BigInt> {
1331 // Python class attribute MAXVAL (self-contained converter).
1332 Some(num_bigint::BigInt::from(10u32).pow(603))
1333 }
1334 /// This language's own `to_currency(currency=...)` default,
1335 /// read from the live Python signature. Only 44 of 156 use EUR.
1336 fn default_currency(&self) -> &str {
1337 "EUR"
1338 }
1339
1340 /// This language's own `to_currency(separator=...)` default,
1341 /// read from the live Python signature. Base's is ",", but only
1342 /// 36 of 149 languages actually use it — most default to " " or a
1343 /// conjunction, so inheriting Base's comma silently corrupts them.
1344 fn default_separator(&self) -> &str {
1345 " ja"
1346 }
1347
1348 // `cards()` / `maxval()` / `merge()` intentionally stay at their trait
1349 // defaults: FI's tables are not `base::Cards` and its `merge` needs the
1350 // structured value plus an `Options`, so the shared engine is bypassed
1351 // entirely. See the module docs.
1352
1353 fn negword(&self) -> &str {
1354 "miinus "
1355 }
1356
1357 fn pointword(&self) -> &str {
1358 "pilkku"
1359 }
1360
1361 fn exclude_title(&self) -> &[String] {
1362 &self.exclude_title
1363 }
1364
1365 /// Port of `Num2Word_FI.to_cardinal` for integral input.
1366 ///
1367 /// Note FI concatenates `self.negword` **raw** (`out = self.negword`),
1368 /// unlike `Num2Word_Base.to_cardinal`, which rebuilds it as
1369 /// `"%s " % self.negword.strip()`. Both yield "miinus " here, so the
1370 /// difference is invisible — but it is the reason this is written out
1371 /// rather than delegated to `base::default_to_cardinal`.
1372 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1373 // `is_title` is never set, so `title` inside is a no-op — kept for
1374 // fidelity.
1375 let options = Options { ordinal: false, case: NOM, plural: false, prefer: Vec::new() };
1376 self.to_cardinal_opts(value, &options)
1377 }
1378
1379 /// Port of `Num2Word_FI.to_ordinal`.
1380 ///
1381 /// Re-runs the whole engine against `self.ords`; it does **not**
1382 /// post-process the cardinal. Negatives raise `TypeError` via
1383 /// `verify_ordinal` before anything else happens.
1384 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1385 let options = Options { ordinal: true, case: NOM, plural: false, prefer: Vec::new() };
1386 self.to_ordinal_opts(value, &options)
1387 }
1388
1389 /// Port of `Num2Word_FI.to_ordinal_num`: `str(value) + "."`.
1390 ///
1391 /// No `verify_ordinal`, no MAXVAL check — so unlike `to_ordinal` it happily
1392 /// accepts negatives: `to_ordinal_num(-1)` == "-1.".
1393 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1394 Ok(format!("{}.", value))
1395 }
1396
1397 /// Port of `Num2Word_FI.to_year` with `suffix=None`.
1398 ///
1399 /// `self.to_cardinal(val).replace(" ", "") + suffix`: every space inside the
1400 /// cardinal is squeezed out (so 1005 → "tuhatviisi", not "tuhat viisi"),
1401 /// but the BC suffix keeps its own leading space because it is appended
1402 /// afterwards. The sign is stripped before `to_cardinal`, so a BC year
1403 /// never gets "miinus".
1404 fn to_year(&self, value: &BigInt) -> Result<String> {
1405 let mut val = value.clone();
1406 // suffix = suffix or "" -> "", which is falsy, hence the BC default.
1407 let mut suffix = "";
1408 if val.is_negative() {
1409 val = val.abs();
1410 suffix = " ennen ajanlaskun alkua";
1411 }
1412 Ok(format!("{}{}", self.to_cardinal(&val)?.replace(' ', ""), suffix))
1413 }
1414
1415 // ---- float/Decimal entry routing --------------------------------------
1416 //
1417 // `cardinal_float_entry` stays at the trait default: FI's
1418 // `assert int(value) == value` routing *is* base semantics (whole ->
1419 // integer engine, fractional -> to_cardinal_float), verified against the
1420 // frozen corpus. Only ordinal/ordinal_num/year need overrides.
1421
1422 /// `Num2Word_FI.to_ordinal(float/Decimal)` — `Num2Word_Base.verify_ordinal`
1423 /// runs first (after the NAME_TO_CASE lookup, which the no-kwargs entry
1424 /// cannot fail):
1425 ///
1426 /// * fractional (`not value == int(value)`) → TypeError errmsg_floatord —
1427 /// checked **before** the sign, so `to_ordinal(-1.5)` is the floatord
1428 /// message;
1429 /// * negative (`not abs(value) == value`) → TypeError errmsg_negord.
1430 /// `abs(-0.0) == -0.0` is true, so **-0.0 passes** and yields "nollas";
1431 /// * whole non-negative → the ordinal engine, same words as the int path
1432 /// (`to_ordinal(5.0)` == "viides", `to_ordinal(Decimal("1E+2"))` ==
1433 /// "sadas").
1434 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1435 match value.as_whole_int() {
1436 None => Err(N2WError::Type(format!(
1437 "Cannot treat float {} as ordinal.",
1438 float_msg_repr(value)
1439 ))),
1440 Some(i) => {
1441 if i.is_negative() {
1442 return Err(N2WError::Type(format!(
1443 "Cannot treat negative num {} as ordinal.",
1444 float_msg_repr(value)
1445 )));
1446 }
1447 self.to_ordinal(&i)
1448 }
1449 }
1450 }
1451
1452 /// `Num2Word_FI.to_ordinal_num(float/Decimal)`: `str(value) + "."`, no
1453 /// validation whatsoever — negatives and fractions included
1454 /// (`to_ordinal_num(-1.5)` == "-1.5.", `to_ordinal_num(1e+16)` == "1e+16.").
1455 /// `repr_str` is Python's `str(value)` as computed by the binding.
1456 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
1457 Ok(format!("{}.", repr_str))
1458 }
1459
1460 /// `Num2Word_FI.to_year(float/Decimal)`. `val < 0` is a *value* test —
1461 /// false for -0.0 (unlike `FloatValue::is_negative`, which reads the sign
1462 /// bit), so `to_year(-0.0)` == "nolla" with no BC suffix. The cardinal
1463 /// (int engine for whole values, decimal grammar for fractional) then has
1464 /// every space squeezed out, and the BC suffix keeps its own leading
1465 /// space: `to_year(-1.5)` == "yksipilkkuviisi ennen ajanlaskun alkua".
1466 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1467 let (below_zero, abs) = match value {
1468 FloatValue::Float { value: f, precision } => (
1469 *f < 0.0,
1470 FloatValue::Float { value: f.abs(), precision: *precision },
1471 ),
1472 FloatValue::Decimal { value: d, precision } => (
1473 d.is_negative(),
1474 FloatValue::Decimal { value: d.abs(), precision: *precision },
1475 ),
1476 };
1477 let (val, suffix) = if below_zero {
1478 (&abs, " ennen ajanlaskun alkua")
1479 } else {
1480 (value, "")
1481 };
1482 let cardinal = self.cardinal_float_entry(val, None)?;
1483 Ok(format!("{}{}", cardinal.replace(' ', ""), suffix))
1484 }
1485
1486 // ---- grammatical kwargs ------------------------------------------------
1487
1488 /// `to_cardinal(value, case="nominative", plural=False, prefer=None)`.
1489 fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1490 if !kw.only(&["case", "plural", "prefer"]) {
1491 return Err(N2WError::Fallback("kwargs".into()));
1492 }
1493 let options = self.options_from_kwargs(kw, false)?;
1494 self.to_cardinal_opts(value, &options)
1495 }
1496
1497 /// `to_ordinal(value, case="nominative", plural=False, prefer=None)`.
1498 ///
1499 /// `NAME_TO_CASE[case]` runs before `verify_ordinal`, so
1500 /// `to_ordinal(-5, case="bogus")` is **KeyError**, not TypeError —
1501 /// `options_from_kwargs` resolves the case first.
1502 fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1503 if !kw.only(&["case", "plural", "prefer"]) {
1504 return Err(N2WError::Fallback("kwargs".into()));
1505 }
1506 let options = self.options_from_kwargs(kw, true)?;
1507 self.to_ordinal_opts(value, &options)
1508 }
1509
1510 /// `to_ordinal_num(value, case="nominative", plural=False)`: the body never
1511 /// reads either kwarg (no NAME_TO_CASE lookup!), so `case="bogus"` is
1512 /// accepted and the result is still `str(value) + "."`.
1513 fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1514 if !kw.only(&["case", "plural"]) {
1515 return Err(N2WError::Fallback("kwargs".into()));
1516 }
1517 Ok(format!("{}.", value))
1518 }
1519
1520 /// `to_year(val, suffix=None, longval=True)`. `longval` is never read.
1521 ///
1522 /// `suffix = suffix or ""` first, then — negatives only — `suffix = suffix
1523 /// or " ennen ajanlaskun alkua"`: a caller-supplied *truthy* suffix wins
1524 /// over the BC default, an empty/None one falls through to it.
1525 fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1526 if !kw.only(&["suffix", "longval"]) {
1527 return Err(N2WError::Fallback("kwargs".into()));
1528 }
1529 let mut suffix: String = match kw.get("suffix") {
1530 None | Some(KwVal::None) => String::new(),
1531 Some(KwVal::Str(s)) => s.clone(),
1532 // A non-string suffix would concatenate onto str in Python
1533 // (TypeError) only after conversion; fall back to Python.
1534 Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1535 };
1536 let mut val = value.clone();
1537 if val.is_negative() {
1538 val = val.abs();
1539 if suffix.is_empty() {
1540 suffix = " ennen ajanlaskun alkua".to_string();
1541 }
1542 }
1543 Ok(format!("{}{}", self.to_cardinal(&val)?.replace(' ', ""), suffix))
1544 }
1545
1546 /// `to_cardinal(float/Decimal, case=..., plural=..., prefer=...)`.
1547 ///
1548 /// Python resolves `case` first (KeyError on a bad name, even for
1549 /// fractional input), then routes: whole → the integer engine *with* the
1550 /// options; fractional → NotImplementedError unless the case is
1551 /// nominative, in which case `to_cardinal_float` runs and `plural`/
1552 /// `prefer` are silently dropped.
1553 fn to_cardinal_float_kw(
1554 &self,
1555 value: &FloatValue,
1556 precision_override: Option<u32>,
1557 kw: &Kwargs,
1558 ) -> Result<String> {
1559 if !kw.only(&["case", "plural", "prefer"]) {
1560 return Err(N2WError::Fallback("kwargs".into()));
1561 }
1562 let options = self.options_from_kwargs(kw, false)?;
1563 if let Some(i) = value.as_whole_int() {
1564 return self.to_cardinal_opts(&i, &options);
1565 }
1566 if options.case != NOM {
1567 // Python: raise NotImplementedError(...). NotImplemented also
1568 // makes the live binding fall back to Python, which raises the
1569 // very same exception — either route yields NotImplementedError.
1570 return Err(N2WError::NotImplemented(
1571 "Cases other than nominative are not implemented for \
1572 cardinal floating point numbers."
1573 .into(),
1574 ));
1575 }
1576 self.to_cardinal_float(value, precision_override)
1577 }
1578
1579 // ---- currency -------------------------------------------------------
1580 //
1581 // `currency_precision` stays at the trait default (100 for every code):
1582 // FI defines no `CURRENCY_PRECISION`, so `Num2Word_Base`'s empty dict and
1583 // its `.get(code, 100)` are exactly that. `to_cheque`, `money_verbose`,
1584 // `cents_verbose` and `cents_terse` are inherited from `Num2Word_Base`
1585 // unchanged, and the trait defaults already mirror them.
1586
1587 fn lang_name(&self) -> &str {
1588 "Num2Word_FI"
1589 }
1590
1591 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1592 self.currency_forms.get(code)
1593 }
1594
1595 fn currency_adjective(&self, code: &str) -> Option<&str> {
1596 self.currency_adjectives.get(code).copied()
1597 }
1598
1599 /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
1600 ///
1601 /// Python indexes the tuple directly, so a one-form entry with `n != 1`
1602 /// raises IndexError. Every entry in FI's table has exactly two forms, so
1603 /// this is unreachable — but it is mapped to `Index` rather than panicking
1604 /// so the exception type survives if the table ever changes.
1605 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1606 let form = if n.is_one() { 0 } else { 1 };
1607 forms
1608 .get(form)
1609 .cloned()
1610 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1611 }
1612
1613 /// Port of `Num2Word_FI.to_currency`.
1614 ///
1615 /// FI intercepts **integers only** and hand-rolls the whole string; every
1616 /// other value is handed straight to `Num2Word_Base.to_currency` via
1617 /// `super()`. The int branch is not a re-spelling of Base's — it differs in
1618 /// three observable ways, all reproduced here:
1619 ///
1620 /// 1. **The double space.** Base builds its sign as
1621 /// `"%s " % self.negword.strip()`; FI concatenates `self.negword` *raw*
1622 /// into `"%s %s %s"`. `negword` is `"miinus "` — already trailing a
1623 /// space — so the format's own separator lands on top of it and
1624 /// `to_currency(-2, "EUR")` is **`"miinus kaksi euroa"`**, with two
1625 /// spaces. The trailing `.strip()` only touches the ends, so the seam
1626 /// survives. Floats route to Base and get a single space, meaning
1627 /// `-2` and `-2.0` disagree about their own spacing.
1628 /// 2. **`adjective` is ignored.** The int branch never calls
1629 /// `prefix_currency`, so `to_currency(2, "USD", adjective=True)` is
1630 /// `"kaksi dollaria"` while `to_currency(2.0, "USD", adjective=True)` is
1631 /// `"kaksi Yhdysvaltain dollaria ja nolla senttiä"`.
1632 /// 3. **`pluralize` is bypassed** for an inline `cr1[0]`/`cr1[1]` pick.
1633 /// Same result as `Num2Word_EUR.pluralize` for FI's uniformly 2-form
1634 /// table, but it is the literal code path.
1635 ///
1636 /// `cents` and `separator` are simply unused by the int branch — Python
1637 /// accepts and drops them, since an int never renders a cents segment.
1638 ///
1639 /// A missing code raises `KeyError` on `self.CURRENCY_FORMS[currency]`,
1640 /// which the `except (KeyError, AttributeError)` turns into a `super()`
1641 /// call — and *that* is what raises the NotImplementedError, carrying
1642 /// `self.__class__.__name__` == `"Num2Word_FI"`. So the fallback is a real
1643 /// delegation, not a shortcut to the error: routing it through
1644 /// `default_to_currency` keeps the message's origin honest.
1645 fn to_currency(
1646 &self,
1647 val: &CurrencyValue,
1648 currency: &str,
1649 cents: bool,
1650 separator: Option<&str>,
1651 adjective: bool,
1652 ) -> Result<String> {
1653 // `separator=" ja"` is FI's own signature default; the trait resolves
1654 // `None` through `default_separator()` for us.
1655 let separator = separator.unwrap_or(self.default_separator());
1656
1657 // "Handle integers specially - just add currency name without cents".
1658 // Note this is `isinstance(val, int)`, so a whole *float* like 1.0 does
1659 // NOT come here — it keeps its cents segment via Base.
1660 if let CurrencyValue::Int(v) = val {
1661 let cr1 = match self.currency_forms.get(currency) {
1662 Some(forms) => &forms.unit,
1663 // KeyError -> "Fallback to base implementation for unknown
1664 // currency", which is where NotImplementedError comes from.
1665 None => {
1666 return default_to_currency(self, val, currency, cents, separator, adjective)
1667 }
1668 };
1669
1670 // `minus_str = self.negword if val < 0 else ""` — raw, unstripped.
1671 let minus_str = if v.is_negative() { self.negword() } else { "" };
1672 let abs_val = v.abs();
1673 let money_str = self.to_cardinal(&abs_val)?;
1674
1675 // `cr1[0]` if abs_val == 1, else `cr1[1] if len(cr1) > 1 else cr1[0]`.
1676 let currency_str = if abs_val.is_one() {
1677 cr1.first()
1678 } else if cr1.len() > 1 {
1679 cr1.get(1)
1680 } else {
1681 cr1.first()
1682 }
1683 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
1684
1685 // ("%s %s %s" % (...)).strip() — see quirk (1) above.
1686 return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
1687 .trim()
1688 .to_string());
1689 }
1690
1691 // "For floats, use the parent class implementation".
1692 default_to_currency(self, val, currency, cents, separator, adjective)
1693 }
1694}