Skip to main content

num2words2_core/
lang_cy.rs

1//! Port of `lang_CY.py` (Welsh).
2//!
3//! Shape: **self-contained**. `Num2Word_CY` subclasses `Num2Word_EUR` →
4//! `Num2Word_Base`, but it overrides `__init__` with a bare `pass`. That single
5//! line severs the whole inherited setup chain:
6//!
7//!   * `Num2Word_Base.__init__` never runs, so `setup()` is never called and
8//!     `Num2Word_EUR.setup`'s `high_numwords` is never built.
9//!   * `self.cards` and `self.MAXVAL` are therefore never created, and
10//!     `set_high_numwords` / `gen_high_numwords` / `splitnum` / `clean` /
11//!     `merge` are all dead code for this language.
12//!   * `self.negword`, `self.pointword`, `self.is_title`, `self.precision` and
13//!     the `errmsg_*` strings are likewise never assigned. CY substitutes its
14//!     own class-level `MINUS_PREFIX_WORD = "meinws "` /
15//!     `FLOAT_INFIX_WORD = " pwynt "` and hardcodes `("meinws", None)` into the
16//!     word list, so nothing in the four in-scope modes ever touches the
17//!     missing attributes.
18//!
19//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here, and
20//! `to_cardinal` is overridden outright. The ceiling is CY's own explicit
21//! `999 * 10**33` guard, which raises **NotImplementedError** — not the
22//! `OverflowError` that `Num2Word_Base.to_cardinal` would have raised.
23//!
24//! Inherited from `Num2Word_Base` (CY does not override either, so the trait
25//! defaults are correct):
26//!   * `to_ordinal_num(value) -> value` → default `Ok(value.to_string())`.
27//!     Note Python returns the **int itself**, not a string; the corpus
28//!     stringifies it, which the trait default reproduces.
29//!   * `to_year(value, **kwargs) -> self.to_cardinal(value)` → the default
30//!     delegates through `&self` and picks up the `to_cardinal` override below.
31//!     Welsh has no special year form; `to_year(1999)` == `to_cardinal(1999)`.
32//!
33//! # How Welsh numerals work here
34//!
35//! The algorithm never builds a plain string until the very end. Every table
36//! entry is a `(word, mutation)` pair where `mutation` describes the consonant
37//! mutation this word triggers on the word that *follows* it (soft "SM" or
38//! aspirate "AM"). [`makestring`] walks the assembled list, applies each
39//! pending mutation to the next word, and joins with single spaces. Numerals
40//! are vigesimal (base-20): 30 is "deg ar hugain" (ten on twenty), 80 is
41//! "pedwar ugain" (four twenties), and 50-59 have a special "hanner (cant)"
42//! (half a hundred) form.
43//!
44//! `OBJ` (`"__OBJECT__"`) is a placeholder marking where a counted noun would
45//! be inserted (Welsh puts the noun *between* numeral parts). The `counted=`
46//! kwarg that fills it is out of scope — the dispatcher's Rust fast path only
47//! fires when no kwargs are present — so every `OBJ` is dropped here. See the
48//! bug list below for why dropping it is nonetheless load-bearing.
49//!
50//! # Faithfully reproduced Python bugs and quirks
51//!
52//! This is a port, not a rewrite. All of the following are exactly what Python
53//! emits, verified against the frozen corpus:
54//!
55//! 1. **`makestring`'s `continue` leaks the pending mutation across `OBJ`.**
56//!    When an `OBJ` entry is skipped (`counted` is None), Python `continue`s
57//!    *before* `lastmut = mut` runs, so the `OBJ`'s own `None` mutation is
58//!    never consumed and the previous word's mutation survives to hit the next
59//!    real word. This is what makes `200` == "dau gant" work: the list is
60//!    `[("dau","SM"), (OBJ,None), ("cant",None)]`, and "cant" is soft-mutated
61//!    to "gant" only because `OBJ` failed to clear the "SM". With a `counted`
62//!    noun present the mutation would land on the noun instead and "cant" would
63//!    stay unmutated. Modelled exactly in [`makestring`].
64//! 2. **`CARDINAL_WORDS[19]` and `CARDINAL_WORDS_FEM[19]` are missing their
65//!    `OBJ`** — every other entry in both tables has one. `19` is the only
66//!    value whose counted noun therefore has nowhere to go. Kept verbatim;
67//!    harmless in scope (all `OBJ`s are dropped anyway) but the asymmetry is
68//!    clearly unintentional.
69//! 3. **`ORDINAL_WORDS_FEM` is dead code.** `hundred_group` picks its table as
70//!    `if gender == "fem": CW = CARDINAL_WORDS_FEM` *before* it ever checks
71//!    `ordinal`, so a feminine ordinal silently falls back to the feminine
72//!    **cardinal** words. Nothing in the module references
73//!    `ORDINAL_WORDS_FEM`. It is transcribed below for the record but,
74//!    faithfully, never read.
75//! 4. **No "a"/"ac" conjunction between magnitude groups.** `1001` is
76//!    "mil un", not "mil ac un"; `2001` is "dwy fil un". The `ac`/`a` join is
77//!    only ever emitted *inside* `hundred_group`, between a hundreds part and
78//!    its own remainder.
79//! 5. **The `ac` list is tested against `until100`, not against the units.**
80//!    So `1776` → "mil saith cant **a un** ar bymtheg a thrigain": the
81//!    remainder is 76, which is not in the `ac` list, so "a" is chosen even
82//!    though the word it precedes is "un" (which *would* have taken "ac" had
83//!    the check been on the unit). Reproduced by [`AC_VALUES`].
84//! 6. **`lowestgroup` compares group *values*, not positions.**
85//!    `ordinal and (lowestgroup == gr)` matches any group whose value equals
86//!    the lowest non-zero group's value, so `to_cardinal(1_000_001, ordinal=True)`
87//!    would ordinalize both the millions and the units group. Unreachable from
88//!    the four in-scope modes (see `to_ordinal`'s range gate below), but the
89//!    comparison is ported as written rather than "fixed" to a position check.
90//! 7. **`to_ordinal` raises `KeyError` for every negative input.** The guard is
91//!    `if number < 20: return makestring(ORDINAL_WORDS[number])`, and every
92//!    negative satisfies `< 20`, so `ORDINAL_WORDS[-1]` misses the dict. It is
93//!    a crash, not a deliberate raise, but the exception *type* is observable —
94//!    hence `N2WError::Key` and not a tidy `TypeError`. `to_cardinal` is
95//!    unaffected (it strips the sign first) and `to_ordinal_num` is unaffected
96//!    (it returns the input untouched).
97//!
98//! # Float / Decimal routing (the entry hooks)
99//!
100//! CY's `isinstance(number, float)` guard fires for **every** float, whole
101//! values included: `to_cardinal(1.0)` is "un pwynt dim", never "un". So
102//! [`Lang::cardinal_float_entry`] is overridden to skip the base default's
103//! whole-value shortcut entirely — floats always take [`LangCy::float_to_words`],
104//! Decimals always take the integer branch ([`LangCy::decimal_to_cardinal`]).
105//! `to_year` is Base's `to_cardinal(value)`, so the default
106//! `year_float_entry` (→ `cardinal_float_entry`) is already right.
107//!
108//! `to_ordinal(float/Decimal)` is its own zoo ([`Lang::ordinal_float_entry`]):
109//!
110//! * `number < 20` → `ORDINAL_WORDS[number]`. A float/Decimal key hits the
111//!   dict iff it hash-equals an int key 0..=19: `5.0` → "pumed",
112//!   `-0.0` → "dimfed" (`-0.0 == 0`), while `0.5`, `-1.0`, `Decimal('-3.0')`
113//!   all raise **KeyError**.
114//! * `number == 100` → "canfed" (`1E+2` included); `> 100` →
115//!   NotImplementedError — so `to_ordinal(101.0)` raises where
116//!   `to_cardinal(101.0)` renders.
117//! * 20 ≤ n < 100 → `to_cardinal(n, ordinal=True)`. For a *float* the
118//!   isinstance guard wins first and `ordinal=True` is silently ignored:
119//!   `to_ordinal(21.0)` is the **cardinal** "un ar hugain pwynt dim". A
120//!   Decimal reaches the integer branch with `ordinal=True` live:
121//!   `to_ordinal(Decimal('42'))` is "ail a deugain".
122//!
123//! `float_to_words` reads the digits out of `str(abs_float)`. Python repr
124//! picks exponent notation at `abs >= 1e16` (and below `1e-4`), where
125//! `.split(".")[1]` raises **IndexError** ("1e+16" has no dot) or the
126//! digit-by-digit `int(c)` hits the 'e' and raises **ValueError**
127//! ("1.5e+16"). The prefix `to_cardinal(int(abs_float))` is computed *before*
128//! the split, so a float past `999 * 10**33` raises NotImplementedError
129//! first. Reproduced via [`py_float_str`].
130//!
131//! `Decimal('Infinity')` / `Decimal('NaN')` parse fine in `str_to_number` and
132//! only blow up inside `to_cardinal`: the `not number < 999 * 10**33` ceiling
133//! raises **NotImplementedError** for ±Infinity, and `number < 0` raises
134//! **decimal.InvalidOperation** for NaN. The binding's generic Inf/NaN arms
135//! raise Base's OverflowError/ValueError instead, so [`Lang::str_to_number`]
136//! is overridden to return NotImplemented for Inf/NaN — the shim then reruns
137//! the original Python string path, which owns those raises exactly.
138//!
139//! # Fractions
140//!
141//! CY inherits `Num2Word_Base.to_fraction`, whose negative branch is
142//! `sign = "%s " % self.negword.strip()` — and the `pass` `__init__` never
143//! created `self.negword`, so **any negative fraction raises AttributeError**
144//! (`-1/2`, `1/-2`; `-3/-4` is positive and renders). The sign is computed
145//! before the numerator/denominator words, so the AttributeError beats the
146//! KeyError/NotImplementedError those would raise. Ported as a local
147//! override rather than the trait default precisely for that raise.
148//!
149//! # Grammatical kwargs
150//!
151//! `to_cardinal(number, informal=False, gender="masc", ordinal=False,
152//! counted=None, raw=False)` and `to_ordinal(number, informal=False,
153//! gender="masc")` are the live signatures ([`Lang::to_cardinal_kw`] /
154//! [`Lang::to_ordinal_kw`]):
155//!
156//! * `informal` is accepted and ignored (its only use site is commented out).
157//! * `gender` only ever matters as the literal comparison `== "fem"` — "f",
158//!   "m", None, 1 all fall to masculine.
159//! * `counted` fills the first `OBJ` below 100 ("un ci ar hugain") and turns
160//!   into the partitive `o <noun>` (soft mutation: "o gi") at 100 and above.
161//!   Zero returns before `counted` is consulted: `to_cardinal(0, counted="ci")`
162//!   is plain "dim".
163//! * `raw=True` returns the word list itself; the corpus stringifies it, so
164//!   [`py_repr_wordlist`] reproduces the Python `repr` of a list of
165//!   `(str, str|None)` tuples byte for byte.
166//! * `ordinal=` never reaches the converter through `num2words` — the
167//!   dispatcher's own `ordinal` parameter shadows it and rewrites `to`
168//!   — so it is deliberately NOT in the kwargs guard.
169//!
170//! `to_year(value, **kwargs)` is Base's and swallows *every* kwarg, so
171//! [`Lang::to_year_kw`] accepts anything and returns the cardinal.
172//! `to_ordinal_num` and `to_currency` take no extra kwargs — the trait
173//! defaults (fall back to Python's TypeError) are already exact.
174//!
175//! # The currency surface
176//!
177//! `Num2Word_CY` overrides `to_currency`, `_money_verbose` and
178//! `_cents_verbose`; it inherits `pluralize` from `Num2Word_EUR` and
179//! `to_cheque` / `_cents_terse` from `Num2Word_Base` (all four confirmed on the
180//! live interpreter via `__func__.__qualname__`).
181//!
182//! Two consequences of the `pass` __init__ dominate this surface:
183//!
184//! 1. **`self.negword` never exists**, and `Num2Word_Base.to_currency` reaches
185//!    for it — `minus_str = "%s " % self.negword.strip() if is_negative else ""`.
186//!    Python's conditional expression only evaluates the left operand when
187//!    `is_negative` is true, so a *positive* float is fine and a **negative
188//!    float raises `AttributeError`**. See [`N2WError::Attribute`] below and
189//!    module bug 8.
190//! 2. **`CURRENCY_PRECISION` is `Num2Word_Base`'s shared `{}`** (verified:
191//!    `Num2Word_CY.CURRENCY_PRECISION is Num2Word_Base.CURRENCY_PRECISION`).
192//!    `Num2Word_EN.__init__` *rebinds* rather than mutates it, so EN's mils
193//!    table does not leak here. Every code is therefore divisor 100 — CY has no
194//!    3-decimal and no 0-decimal currency, and `currency_precision` stays at its
195//!    trait default. The `divisor == 1` branch of `default_to_currency` is
196//!    unreachable for Welsh.
197//!
198//! ## `CURRENCY_FORMS` is CY's own dict, not the mutated EUR one
199//!
200//! The usual `lang_EUR.py` trap does **not** apply: `Num2Word_CY` rebinds
201//! `CURRENCY_FORMS` in its own class body, so it is a fresh dict object and
202//! `Num2Word_EN.__init__`'s in-place mutation of `Num2Word_EUR.CURRENCY_FORMS`
203//! never touches it (verified: `Num2Word_CY.CURRENCY_FORMS is
204//! Num2Word_EUR.CURRENCY_FORMS` → `False`). It holds exactly four codes —
205//! EUR, USD, GBP, CNY — and EN's ~24 added codes are **absent**. JPY, KWD, BHD,
206//! INR and CHF all raise `NotImplementedError` on the float path, as the corpus
207//! records.
208//!
209//! `CURRENCY_ADJECTIVES`, by contrast, *is* the shared `Num2Word_EUR` object
210//! (verified `is` → `True`) and nothing mutates it, so its 16 entries are
211//! inherited verbatim. Only `USD` is both adjectivised and present in CY's
212//! forms, so `adjective=True` is observable for USD alone.
213//!
214//! # More faithfully reproduced Python bugs (currency)
215//!
216//! 8. **`to_currency` ignores `currency=` entirely for `int` input.** CY's
217//!    override intercepts `isinstance(val, int)` *before* delegating to
218//!    `super()`, and its branch never consults `CURRENCY_FORMS`. So every int
219//!    is denominated in mutated GBP — `to_currency(2, "JPY")` is
220//!    `"dau bunnoedd"`, not an error and not yen. This is why the corpus shows
221//!    `currency:JPY` / `currency:KWD` / `currency:CHF` succeeding for `0`, `1`,
222//!    `2`, `100`, `1000000` while every float with the same code raises
223//!    `NotImplementedError`. Reproduced in [`LangCy::to_currency`].
224//! 9. **The int branch says `"minws "`, not `"meinws "`.** CY's own
225//!    `MINUS_PREFIX_WORD` is `"meinws "` and `to_cardinal` hardcodes
226//!    `("meinws", None)`, but `to_currency` spells the negative prefix
227//!    `"minws "`. A typo, kept verbatim: `to_currency(-1)` is `"minws un bunt"`.
228//! 10. **The unit words in the int branch are pre-mutated literals.**
229//!     `"bunt"` / `"bunnoedd"` are the soft-mutated forms of `punt`/`punnoedd`,
230//!     hardcoded as strings rather than produced by [`softmutation`] — so the
231//!     mutation fires even when nothing precedes it that would trigger one
232//!     (`"dim bunnoedd"` after "dim", which triggers no mutation at all).
233//! 11. **`_cents_verbose(1, ...)` drops the numeral.** The `number > 1` guard
234//!     sends 1 to `m = [(OBJ, None)]`, so `0.01` renders `"... ceiniog ceiniog"`
235//!     — the counted noun and `pluralize`'s singular, with no "un".
236//! 12. **`_money_verbose` always asks `to_cardinal` for the *feminine* form**,
237//!     even for masculine currencies: `2.0 USD` is `"dwy dolar dolar, ..."`.
238//!     The dead `if currency in CURRENCIES_FEM` guard is commented out in the
239//!     source with the note "always true in this context". `_cents_verbose`, by
240//!     contrast, leaves gender at its masculine default.
241//! 13. **Every float prints the unit twice.** `_money_verbose` already appends
242//!     the currency noun (as `counted=`, or as the `o <plural>` partitive above
243//!     100), and then `Num2Word_Base.to_currency` appends `pluralize(left, cr1)`
244//!     on top: `"deuddeg euro euros"`, `"mil ... o euros euros"`.
245//! 14. **Zero cents leave a double space.** `_cents_verbose(0, ...)` returns
246//!     `""`, and Base's `"%s%s %s%s %s %s"` template still emits the spaces
247//!     around it: `1.0` → `"un euro euro,  ceiniogau"` (two spaces after the
248//!     comma). The `has_decimal` guard keeps the segment alive for `1.0`.
249//!
250//! # Error variants
251//!
252//! * `NotImplementedError` → [`N2WError::NotImplemented`]: `to_ordinal(n)` for
253//!   `n > 100`, `to_cardinal(n)` for `abs(n) >= 999 * 10**33`, and a currency
254//!   code outside CY's four on the float/cheque paths.
255//! * `KeyError` → [`N2WError::Key`]: `to_ordinal(n)` for `n < 0` (bug 7).
256//! * `AttributeError` → [`N2WError::Attribute`]: `to_currency(<negative float>)`
257//!   for a *known* code — the missing `self.negword` (bug 8's sibling). A
258//!   negative float with an *unknown* code raises `NotImplementedError` instead,
259//!   because Base looks `CURRENCY_FORMS` up before it touches `negword`.
260
261use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
262use crate::currency::{CurrencyForms, CurrencyValue};
263use crate::floatpath::FloatValue;
264use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
265use bigdecimal::BigDecimal;
266use num_bigint::BigInt;
267use num_integer::Integer;
268use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
269use std::collections::HashMap;
270
271/// Python's `OBJ = "__OBJECT__"` sentinel: where a counted noun would be
272/// spliced in. Compared by value in `makestring`, exactly as Python does.
273const OBJ: &str = "__OBJECT__";
274
275/// The mutation a word triggers on the word that *follows* it.
276///
277/// Python stores `None` / `"SM"` / `"AM"` in the second tuple slot; `Option<Mut>`
278/// mirrors that. `mutate()` in Python has no `else` (the comment says "does not
279/// occur"), so it would return `None` for any other tag — unreachable, since
280/// `makestring` only calls it under `if lastmut:` and the tables hold nothing
281/// else.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283enum Mut {
284    /// "SM" — soft mutation (treiglad meddal).
285    Soft,
286    /// "AM" — aspirate mutation (treiglad llaes).
287    Aspirate,
288}
289
290/// One `(word, triggers_mutation)` table entry.
291type Entry = (&'static str, Option<Mut>);
292
293/// The same pair, but borrowing a word that need not be `'static`.
294///
295/// `_money_verbose` / `_cents_verbose` splice a currency noun out of
296/// `CURRENCY_FORMS` (a `String` owned by [`LangCy`]) into the middle of a word
297/// list, so those lists cannot be `Vec<Entry>`. `Entry` coerces to `Word<'a>`
298/// for any `'a`, which lets both feed the one [`makestring`].
299type Word<'a> = (&'a str, Option<Mut>);
300
301use Mut::{Aspirate as AM, Soft as SM};
302
303/// `CARDINAL_WORDS`, keys 0..=19 (masculine).
304///
305/// Index 19 is missing its `OBJ` — see module bug 2.
306const CARDINAL_WORDS: [&[Entry]; 20] = [
307    /*  0 */ &[("dim", None), (OBJ, None)],
308    /*  1 */ &[("un", None), (OBJ, None)],
309    /*  2 */ &[("dau", Some(SM)), (OBJ, None)],
310    /*  3 */ &[("tri", Some(AM)), (OBJ, None)],
311    /*  4 */ &[("pedwar", None), (OBJ, None)],
312    /*  5 */ &[("pump", None), (OBJ, None)],
313    /*  6 */ &[("chwech", Some(AM)), (OBJ, None)],
314    /*  7 */ &[("saith", None), (OBJ, None)],
315    /*  8 */ &[("wyth", None), (OBJ, None)],
316    /*  9 */ &[("naw", None), (OBJ, None)],
317    /* 10 */ &[("deg", None), (OBJ, None)],
318    /* 11 */ &[("un", None), (OBJ, None), ("ar ddeg", None)],
319    /* 12 */ &[("deuddeg", None), (OBJ, None)],
320    /* 13 */ &[("tri", Some(AM)), (OBJ, None), ("ar ddeg", None)],
321    /* 14 */ &[("pedwar", None), (OBJ, None), ("ar ddeg", None)],
322    /* 15 */ &[("pymtheg", None), (OBJ, None)],
323    /* 16 */ &[("un", None), (OBJ, None), ("ar bymtheg", None)],
324    /* 17 */ &[("dau", Some(SM)), (OBJ, None), ("ar bymtheg", None)],
325    /* 18 */ &[("deunaw", None), (OBJ, None)],
326    /* 19 */ &[("pedwar", None), ("ar bymtheg", None)], // no OBJ — bug 2
327];
328
329/// `CARDINAL_WORDS_FEM`, keys 0..=19 (feminine).
330///
331/// Index 19 is missing its `OBJ` — see module bug 2.
332const CARDINAL_WORDS_FEM: [&[Entry]; 20] = [
333    /*  0 */ &[("dim", None), (OBJ, None)],
334    /*  1 */ &[("un", None), (OBJ, None)],
335    /*  2 */ &[("dwy", Some(SM)), (OBJ, None)],
336    /*  3 */ &[("tair", None), (OBJ, None)],
337    /*  4 */ &[("pedair", None), (OBJ, None)],
338    /*  5 */ &[("pump", None), (OBJ, None)],
339    /*  6 */ &[("chwech", Some(AM)), (OBJ, None)],
340    /*  7 */ &[("saith", None), (OBJ, None)],
341    /*  8 */ &[("wyth", None), (OBJ, None)],
342    /*  9 */ &[("naw", None), (OBJ, None)],
343    /* 10 */ &[("deg", None), (OBJ, None)],
344    /* 11 */ &[("un", None), (OBJ, None), ("ar ddeg", None)],
345    /* 12 */ &[("deuddeg", None), (OBJ, None)],
346    /* 13 */ &[("tair", None), (OBJ, None), ("ar ddeg", None)],
347    /* 14 */ &[("pedair", None), (OBJ, None), ("ar ddeg", None)],
348    /* 15 */ &[("pymtheg", None), (OBJ, None)],
349    /* 16 */ &[("un", None), (OBJ, None), ("ar bymtheg", None)],
350    /* 17 */ &[("dwy", Some(SM)), (OBJ, None), ("ar bymtheg", None)],
351    /* 18 */ &[("deunaw", None), (OBJ, None)],
352    /* 19 */ &[("pedair", None), ("ar bymtheg", None)], // no OBJ — bug 2
353];
354
355/// `ORDINAL_WORDS`, keys 0..=19 (masculine).
356const ORDINAL_WORDS: [&[Entry]; 20] = [
357    /*  0 */ &[("dimfed", None), (OBJ, None)],
358    /*  1 */ &[(OBJ, None), ("cyntaf", None)],
359    /*  2 */ &[("ail", Some(SM)), (OBJ, None)],
360    /*  3 */ &[("trydydd", None), (OBJ, None)],
361    /*  4 */ &[("pedwerydd", None), (OBJ, None)],
362    /*  5 */ &[("pumed", None), (OBJ, None)],
363    /*  6 */ &[("chweched", None), (OBJ, None)],
364    /*  7 */ &[("saithfed", None), (OBJ, None)],
365    /*  8 */ &[("wythfed", None), (OBJ, None)],
366    /*  9 */ &[("nawfed", None), (OBJ, None)],
367    /* 10 */ &[("degfed", None), (OBJ, None)],
368    /* 11 */ &[("unfed", Some(SM)), (OBJ, None), ("ar ddeg", None)],
369    /* 12 */ &[("deuddegfed", None), (OBJ, None)],
370    /* 13 */ &[("trydydd", None), (OBJ, None), ("ar ddeg", None)],
371    /* 14 */ &[("pedwerydd", None), (OBJ, None), ("ar ddeg", None)],
372    /* 15 */ &[("pymthegfed", None), (OBJ, None)],
373    /* 16 */ &[("unfed", None), (OBJ, None), ("ar bymtheg", None)],
374    /* 17 */ &[("ail", Some(SM)), (OBJ, None), ("ar bymtheg", None)],
375    /* 18 */ &[("deunawfed", None), (OBJ, None)],
376    /* 19 */ &[("pedwerydd", None), (OBJ, None), ("ar bymtheg", None)],
377];
378
379/// `ORDINAL_WORDS_FEM`, keys 0..=19.
380///
381/// **Dead in Python** — see module bug 3. `hundred_group` tests `gender ==
382/// "fem"` before it tests `ordinal`, so a feminine ordinal gets
383/// `CARDINAL_WORDS_FEM` instead and this table is never read. Transcribed for
384/// the record; deliberately left unreferenced so the Rust port has the same
385/// reachable behaviour.
386#[allow(dead_code)]
387const ORDINAL_WORDS_FEM: [&[Entry]; 20] = [
388    /*  0 */ &[("dimfed", None), (OBJ, None)],
389    /*  1 */ &[(OBJ, None), ("gyntaf", None)],
390    /*  2 */ &[("ail", Some(SM)), (OBJ, None)],
391    /*  3 */ &[("trydedd", Some(SM)), (OBJ, None)],
392    /*  4 */ &[("pedwaredd", Some(SM)), (OBJ, None)],
393    /*  5 */ &[("pumed", None), (OBJ, None)],
394    /*  6 */ &[("chweched", None), (OBJ, None)],
395    /*  7 */ &[("saithfed", None), (OBJ, None)],
396    /*  8 */ &[("wythfed", None), (OBJ, None)],
397    /*  9 */ &[("nawfed", None), (OBJ, None)],
398    /* 10 */ &[("degfed", None), (OBJ, None)],
399    /* 11 */ &[("unfed", Some(SM)), (OBJ, None), ("ar ddeg", None)],
400    /* 12 */ &[("deuddegfed", None), (OBJ, None)],
401    /* 13 */ &[("trydedd", Some(SM)), (OBJ, None), ("ar ddeg", None)],
402    /* 14 */ &[("pedwaredd", Some(SM)), (OBJ, None), ("ar ddeg", None)],
403    /* 15 */ &[("pymthegfed", None), (OBJ, None)],
404    /* 16 */ &[("unfed", None), (OBJ, None), ("ar bymtheg", None)],
405    /* 17 */ &[("ail", Some(SM)), (OBJ, None), ("ar bymtheg", None)],
406    /* 18 */ &[("deunawfed", None), (OBJ, None)],
407    /* 19 */ &[("pedwaredd", None), (OBJ, None), ("ar bymtheg", None)],
408];
409
410/// `MILLION_WORDS`: exponent → magnitude word. Python keys are 3..=33 step 3.
411///
412/// Indexed as `MILLION_WORDS[pot - 3]` where `pot` ranges over 6..=36, so the
413/// live key range is exactly 3..=33 and a `KeyError` is unreachable — the
414/// `999 * 10**33` ceiling in `to_cardinal` stops before 10**36 would need a
415/// key 36. Spellings kept verbatim, including "secsttiliwn" (double t) and
416/// "dengiliwn".
417fn million_word(exponent: u32) -> Entry {
418    match exponent {
419        3 => ("mil", None),
420        6 => ("miliwn", None),
421        9 => ("biliwn", None),
422        12 => ("triliwn", None),
423        15 => ("cwadriliwn", None),
424        18 => ("cwintiliwn", None),
425        21 => ("secsttiliwn", None),
426        24 => ("septiliwn", None),
427        27 => ("octiliwn", None),
428        30 => ("noniliwn", None),
429        33 => ("dengiliwn", None),
430        // Python: MILLION_WORDS[pot - 3] → KeyError. Unreachable given the
431        // 999 * 10**33 ceiling; kept as the same exception type regardless.
432        _ => unreachable!("million_word({}) — outside the 999*10**33 ceiling", exponent),
433    }
434}
435
436/// `STR_TENS` values, keys 1..=4 (20, 40, 60, 80).
437const STR_TENS_1: &[Entry] = &[("ugain", None), (OBJ, None)];
438const STR_TENS_2: &[Entry] = &[("deugain", None), (OBJ, None)];
439const STR_TENS_3: &[Entry] = &[("trigain", None), (OBJ, None)];
440const STR_TENS_4: &[Entry] = &[("pedwar ugain", None), (OBJ, None)];
441
442/// `ORD_STR_TENS` values, keys 1..=4.
443const ORD_STR_TENS_1: &[Entry] = &[("ugainfed", None), (OBJ, None)];
444const ORD_STR_TENS_2: &[Entry] = &[("deugainfed", None), (OBJ, None)];
445const ORD_STR_TENS_3: &[Entry] = &[("trigainfed", None), (OBJ, None)];
446const ORD_STR_TENS_4: &[Entry] = &[("pedwar ugainfed", None), (OBJ, None)];
447
448/// `STR_TENS.get(tens)`: vigesimal scores. Returns `None` for `tens == 0`,
449/// which `hundred_group` relies on (a 0-19 remainder inside a hundreds group
450/// emits its unit words with no score attached).
451fn str_tens(tens: i64) -> Option<&'static [Entry]> {
452    match tens {
453        1 => Some(STR_TENS_1),
454        2 => Some(STR_TENS_2),
455        3 => Some(STR_TENS_3),
456        4 => Some(STR_TENS_4),
457        _ => None,
458    }
459}
460
461/// `ORD_STR_TENS.get(tens)`: ordinal forms of the scores.
462fn ord_str_tens(tens: i64) -> Option<&'static [Entry]> {
463    match tens {
464        1 => Some(ORD_STR_TENS_1),
465        2 => Some(ORD_STR_TENS_2),
466        3 => Some(ORD_STR_TENS_3),
467        4 => Some(ORD_STR_TENS_4),
468        _ => None,
469    }
470}
471
472/// The `until100` values that take "ac" rather than "a" after a hundreds part.
473///
474/// Tested against the whole 0-99 remainder, not the unit — see module bug 5.
475const AC_VALUES: [i64; 16] = [1, 8, 11, 16, 20, 21, 31, 36, 41, 48, 61, 68, 71, 81, 88, 91];
476
477// `STR_TENS_INFORMAL` is defined in Python but never read: `hundred_group`'s
478// only use site is commented out (`# if informal: pass`). The `informal=` kwarg
479// is threaded through every call and then ignored. Not ported.
480
481/// Python's `mutate(word, mutation)`.
482///
483/// Python has no `else` arm here (the comment reads "does not occur"), so a tag
484/// other than SM/AM would return `None`. Unreachable: `makestring` only calls
485/// it under `if lastmut:` and the tables hold nothing else. `Mut` makes that
486/// unrepresentable rather than relying on the comment.
487fn mutate(word: &str, mutation: Mut) -> String {
488    match mutation {
489        Mut::Soft => softmutation(word),
490        Mut::Aspirate => aspiratedmutation(word),
491    }
492}
493
494/// Python's `softmutation` (treiglad meddal).
495///
496/// Python indexes `word[0]` / `word[1]` directly. `word[1]` is only reached
497/// after `word[0]` already matched a specific letter, thanks to `and`'s
498/// short-circuit, so a 1-character word like "a" never touches `word[1]`. No
499/// table word is a single p/t/c/d, so the `IndexError` Python would raise on
500/// e.g. `softmutation("p")` is unreachable; `nth(1)` yielding `None` here
501/// stands in for it and cannot be observed.
502///
503/// Indexed by `chars()`, never bytes: all the words are ASCII today, but the
504/// `word[1:]` slice must stay a character slice to be safe.
505fn softmutation(word: &str) -> String {
506    let mut it = word.chars();
507    let c0 = it.next();
508    let c1 = it.next();
509    // Everything after the first character, as Python's `word[1:]`.
510    let rest: String = word.chars().skip(1).collect();
511
512    if c0 == Some('p') && c1 != Some('h') {
513        format!("b{}", rest)
514    } else if c0 == Some('t') && c1 != Some('h') {
515        format!("d{}", rest)
516    } else if c0 == Some('c') && c1 != Some('h') {
517        format!("g{}", rest)
518    } else if c0 == Some('b') || c0 == Some('m') {
519        format!("f{}", rest)
520    } else if c0 == Some('d') && c1 != Some('d') {
521        // Python: "d" + word — the whole word, not the tail. "deg" → "ddeg".
522        format!("d{}", word)
523    } else if word.starts_with("ll") {
524        // Python: word[1:] — drops one l, giving "l...".
525        rest
526    } else if word.starts_with("rh") {
527        format!("r{}", word.chars().skip(2).collect::<String>())
528    } else if word == "ugain" {
529        "hugain".to_string()
530    } else {
531        word.to_string()
532    }
533}
534
535/// Python's `aspiratedmutation` (treiglad llaes).
536///
537/// Note the `word[1] != "h"` guards: "chwech" already starts "ch", so it falls
538/// through to the `else` and is returned unchanged rather than becoming
539/// "chchwech".
540fn aspiratedmutation(word: &str) -> String {
541    let mut it = word.chars();
542    let c0 = it.next();
543    let c1 = it.next();
544    let rest: String = word.chars().skip(1).collect();
545
546    if c0 == Some('p') && c1 != Some('h') {
547        format!("ph{}", rest)
548    } else if c0 == Some('t') && c1 != Some('h') {
549        format!("th{}", rest)
550    } else if c0 == Some('c') && c1 != Some('h') {
551        format!("ch{}", rest)
552    } else {
553        word.to_string()
554    }
555}
556
557/// Python's module-level `makestring(result, counted=None)`.
558///
559/// Concatenates the `(word, mutation)` list, applying each entry's mutation to
560/// the *following* word.
561///
562/// **The `continue` on a dropped `OBJ` is load-bearing** (module bug 1): it
563/// skips `lastmut = mut`, so the pending mutation survives the `OBJ` and lands
564/// on the next real word. `[("dau","SM"), (OBJ,None), ("cant",None)]` is
565/// "dau gant" precisely because of this. `counted` is always `None` in the
566/// four in-scope modes, so every `OBJ` takes that path.
567fn makestring<'a>(result: &[Word<'a>], counted: Option<&str>) -> String {
568    let mut out: Vec<String> = Vec::new();
569    let mut lastmut: Option<Mut> = None;
570    // Python rebinds `counted = None` after the first substitution, so only the
571    // first OBJ is filled.
572    let mut counted = counted;
573
574    // `Word` is Copy, so `&(w, mut_)` binds the fields by value.
575    for &(w, mut_) in result.iter() {
576        let word: &str = if w == OBJ {
577            match counted.take() {
578                // `continue` — does NOT run `lastmut = mut`. See bug 1.
579                None => continue,
580                Some(c) => c,
581            }
582        } else {
583            w
584        };
585
586        match lastmut {
587            Some(m) => out.push(mutate(word, m)),
588            None => out.push(word.to_string()),
589        }
590        lastmut = mut_;
591    }
592    out.join(" ")
593}
594
595/// Python's `str(<float>)` for a finite value, reconstructed.
596///
597/// * Fixed range — `1e-4 <= abs < 1e16` (and 0.0): fixed notation with the
598///   repr-derived `precision` fractional digits, which reproduces the exact
599///   shortest-repr digits (verified byte-for-byte against the live
600///   interpreter over the corpus float set; the existing port relied on the
601///   same equivalence).
602/// * Exponent range — `abs >= 1e16` or `0 < abs < 1e-4`: Python repr picks
603///   exponent form ("1e+16", "5e-05", "1.5e+16"). Rust's `{:e}` is the same
604///   shortest mantissa; only the exponent spelling differs (`e16` → `e+16`,
605///   zero-padded to two digits like CPython).
606///
607/// The sign uses the sign *bit* (`str(-0.0)` is "-0.0"), matching Python's
608/// formatter — callers that need Python's `< 0` semantics (float_to_words)
609/// pass the already-abs()'d value.
610fn py_float_str(value: f64, precision: u32) -> String {
611    let a = value.abs();
612    let mag = if a != 0.0 && (a >= 1e16 || a < 1e-4) {
613        let s = format!("{:e}", a);
614        let (mant, exp) = s.split_once('e').expect("LowerExp always emits an e");
615        let exp: i32 = exp.parse().expect("exponent is a small int");
616        format!(
617            "{}e{}{:02}",
618            mant,
619            if exp < 0 { '-' } else { '+' },
620            exp.abs()
621        )
622    } else {
623        format!("{:.*}", precision as usize, a)
624    };
625    if value.is_sign_negative() {
626        format!("-{}", mag)
627    } else {
628        mag
629    }
630}
631
632/// Python `repr` of the `raw=True` word list — a list of `(str, str|None)`
633/// tuples: `[('dau', 'SM'), ('__OBJECT__', None)]`. No table word contains a
634/// quote, so the single-quote form is always what CPython prints.
635fn py_repr_wordlist(words: &[Entry]) -> String {
636    let items: Vec<String> = words
637        .iter()
638        .map(|(w, m)| {
639            let m_repr = match m {
640                Some(Mut::Soft) => "'SM'",
641                Some(Mut::Aspirate) => "'AM'",
642                None => "None",
643            };
644            format!("('{}', {})", w, m_repr)
645        })
646        .collect();
647    format!("[{}]", items.join(", "))
648}
649
650/// Python truthiness for a kwarg value (`if raw:` and friends).
651fn kw_truthy(v: &KwVal) -> bool {
652    match v {
653        KwVal::Bool(b) => *b,
654        KwVal::Int(i) => *i != 0,
655        KwVal::Str(s) => !s.is_empty(),
656        KwVal::List(l) => !l.is_empty(),
657        KwVal::None => false,
658    }
659}
660
661/// The KeyError payload for `ORDINAL_WORDS[<float/Decimal>]`: Python sets the
662/// missing key itself as the exception arg — `str()` of the float, or the
663/// `Decimal('...')` repr. The corpora record only the type; the message
664/// mirrors what `repr(KeyError.args[0])` would show.
665fn float_key_repr(value: &FloatValue) -> String {
666    match value {
667        FloatValue::Float { value, precision } => py_float_str(*value, *precision),
668        FloatValue::Decimal { value, .. } => {
669            format!("Decimal('{}')", python_decimal_str(value))
670        }
671    }
672}
673
674/// Python's `Num2Word_CY.hundred_group`.
675///
676/// `number` is a single group of three digits (0..=999), so `i64` is provably
677/// wide enough — the caller has already reduced the BigInt via
678/// `(n % 10**pot) // 10**(pot-3)`.
679///
680/// `informal` is accepted and ignored, exactly as in Python (its only use site
681/// is commented out).
682fn hundred_group(number: i64, _informal: bool, gender: &str, ordinal: bool) -> Vec<Entry> {
683    let hundreds = number / 100;
684    let until100 = number % 100; // 0 - 99
685    let mut result: Vec<Entry> = Vec::new();
686
687    // NOTE: the fem branch wins before `ordinal` is ever consulted, so
688    // ORDINAL_WORDS_FEM is unreachable. Module bug 3.
689    let cw: &[&[Entry]; 20] = if gender == "fem" {
690        &CARDINAL_WORDS_FEM
691    } else if ordinal {
692        &ORDINAL_WORDS
693    } else {
694        &CARDINAL_WORDS
695    };
696
697    if hundreds > 0 {
698        if hundreds > 1 {
699            // Python uses CARDINAL_WORDS here unconditionally — never CW — so
700            // the hundreds multiplier is always masculine and always cardinal,
701            // even for a feminine or ordinal group. 300000 is "tri chant mil"
702            // with masculine "tri" although the group is feminine.
703            result.extend_from_slice(CARDINAL_WORDS[hundreds as usize]);
704        }
705        result.extend_from_slice(&[("cant", None), (OBJ, None)]);
706        if until100 != 0 {
707            if AC_VALUES.contains(&until100) {
708                result.push(("ac", None));
709            } else {
710                result.push(("a", Some(AM)));
711            }
712        }
713    }
714
715    if until100 != 0 {
716        if !ordinal && (50..=59).contains(&until100) {
717            // The "half a hundred" idiom: 50-59 are "hanner cant ..." standalone
718            // or "... a hanner ..." after an explicit hundreds part. Cardinal
719            // only — ordinals fall through to the vigesimal branch, which is why
720            // to_ordinal(50) is "degfed a deugain" while to_cardinal(50) is
721            // "hanner cant".
722            let units = number % 10;
723            if hundreds > 0 {
724                if units == 0 {
725                    result.push(("hanner", None));
726                } else if units == 1 {
727                    result.extend_from_slice(&[("hanner ac un", None), (OBJ, None)]);
728                } else {
729                    result.push(("hanner a", Some(AM)));
730                    result.extend_from_slice(cw[units as usize]);
731                }
732            } else if units == 0 {
733                result.extend_from_slice(&[("hanner cant", None), (OBJ, None)]);
734            } else if units == 1 {
735                result.extend_from_slice(&[("hanner cant ac un", None), (OBJ, None)]);
736            } else {
737                result.push(("hanner cant a", Some(AM)));
738                result.extend_from_slice(cw[units as usize]);
739            }
740        } else if (number < 20 && number > 0) || (number == 0 && hundreds == 0) {
741            // Python indexes the *cardinal* tables directly here, ignoring CW —
742            // so this branch is never ordinal. Unreachable for ordinals anyway:
743            // to_ordinal handles < 20 itself and never delegates here.
744            if gender == "fem" {
745                result.extend_from_slice(CARDINAL_WORDS_FEM[number as usize]);
746            } else {
747                result.extend_from_slice(CARDINAL_WORDS[number as usize]);
748            }
749        } else {
750            // Vigesimal: `tens` counts whole scores of 20 in the 0-99
751            // remainder, `units` is the residue mod 20 of the *whole group*
752            // (identical to until100 % 20, since 100 % 20 == 0).
753            let tens = until100 / 20;
754            let units = number % 20;
755            let degau = if ordinal && units == 0 {
756                ord_str_tens(tens)
757            } else {
758                str_tens(tens)
759            };
760
761            if units != 0 {
762                if tens > 1 {
763                    // 40/60/80 take "a" (aspirate): 42 → "dau a deugain".
764                    result.extend_from_slice(cw[units as usize]);
765                    if let Some(d) = degau {
766                        result.push(("a", Some(AM)));
767                        result.extend_from_slice(d);
768                    }
769                } else {
770                    // 20 takes "ar" (soft): 21 → "un ar hugain". tens == 0
771                    // leaves degau None, so 1-19 within a hundreds group emit
772                    // just the unit words.
773                    result.extend_from_slice(cw[units as usize]);
774                    if let Some(d) = degau {
775                        result.push(("ar", Some(SM)));
776                        result.extend_from_slice(d);
777                    }
778                }
779            } else if let Some(d) = degau {
780                result.extend_from_slice(d);
781            }
782        }
783    }
784    result
785}
786
787/// The `999 * 10**33` ceiling from `to_cardinal`.
788fn maxval_cy() -> BigInt {
789    BigInt::from(999) * BigInt::from(10u32).pow(33)
790}
791
792/// `Num2Word_CY.CURRENCY_FORMS` — CY's **own** class-body dict, all four codes.
793///
794/// Not the EUR dict EN mutates: CY rebinds the name, so this is a distinct
795/// object holding only what `lang_CY.py` lists. Everything EN adds to the EUR
796/// table (AUD, CAD, JPY, KWD, BHD, CHF, INR, ...) is absent here and must stay
797/// absent — the corpus expects `NotImplementedError` for each on the float path.
798///
799/// `GENERIC_DOLLARS` / `GENERIC_CENTS` are `lang_CY.py`'s own module-level
800/// constants, not the same-named ones in `lang_EUR.py` ("dollar"/"cent"); the
801/// Welsh module shadows them with "dolar"/"ceiniog".
802fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
803    const GENERIC_DOLLARS: [&str; 2] = ["dolar", "dolarau"];
804    const GENERIC_CENTS: [&str; 2] = ["ceiniog", "ceiniogau"];
805
806    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
807    m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &GENERIC_CENTS));
808    m.insert("USD", CurrencyForms::new(&GENERIC_DOLLARS, &GENERIC_CENTS));
809    // Python spells GBP's subunit out inline instead of reusing GENERIC_CENTS.
810    // Identical content; transcribed as written.
811    m.insert(
812        "GBP",
813        CurrencyForms::new(&["punt", "punnoedd"], &["ceiniog", "ceiniogau"]),
814    );
815    m.insert("CNY", CurrencyForms::new(&["yuan", "yuans"], &["ffen", "ffens"]));
816    m
817}
818
819/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited untouched.
820///
821/// CY defines none of its own and this is literally the EUR class dict (`is`
822/// → `True` on the live interpreter), which nothing mutates. Only `USD` is both
823/// listed here and present in [`build_currency_forms`], so `adjective=True` can
824/// only ever be observed as `"US dolar"` / `"US dolarau"` — the other fifteen
825/// codes fail the `CURRENCY_FORMS` lookup first. Ported whole regardless: it is
826/// the inherited data, and the `currency in self.CURRENCY_ADJECTIVES` test reads
827/// all of it.
828fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
829    [
830        ("AUD", "Australian"),
831        ("BYN", "Belarusian"),
832        ("CAD", "Canadian"),
833        ("EEK", "Estonian"),
834        ("USD", "US"),
835        ("RUB", "Russian"),
836        ("NOK", "Norwegian"),
837        ("MXN", "Mexican"),
838        ("RON", "Romanian"),
839        ("INR", "Indian"),
840        ("HUF", "Hungarian"),
841        ("ISK", "íslenskar"),
842        ("UZS", "Uzbekistan"),
843        ("SAR", "Saudi"),
844        ("JPY", "Japanese"),
845        ("KRW", "Korean"),
846    ]
847    .into_iter()
848    .collect()
849}
850
851pub struct LangCy {
852    currency_forms: HashMap<&'static str, CurrencyForms>,
853    currency_adjectives: HashMap<&'static str, &'static str>,
854}
855
856impl Default for LangCy {
857    fn default() -> Self {
858        Self::new()
859    }
860}
861
862impl LangCy {
863    pub fn new() -> Self {
864        LangCy {
865            // Built once here, never per call — `to_currency` only ever reads
866            // these tables.
867            currency_forms: build_currency_forms(),
868            currency_adjectives: build_currency_adjectives(),
869        }
870    }
871
872    /// Python's `Num2Word_CY.to_cardinal(..., raw=True)`: the word list, before
873    /// [`makestring`] flattens it.
874    ///
875    /// Split out of `to_cardinal_full` unchanged. `raw=True` is exactly the same
876    /// computation minus the trailing `makestring` call, and `_money_verbose` /
877    /// `_cents_verbose` need the list form so they can splice the currency noun
878    /// into it (that is the *only* reason `raw` exists in Python).
879    ///
880    /// The float branch (`isinstance(number, float)` → `float_to_words`) is out
881    /// of scope: integer input only.
882    fn to_cardinal_raw(
883        &self,
884        number: &BigInt,
885        informal: bool,
886        gender: &str,
887        ordinal: bool,
888    ) -> Result<Vec<Entry>> {
889        let mut negative = false;
890        let mut number = number.clone();
891        if number.is_negative() {
892            negative = true;
893            number = -number;
894        }
895        if number.is_zero() {
896            // Python: `if raw: return CARDINAL_WORDS[0]`. Note this is the
897            // *masculine* table even when gender="fem" — the zero guard runs
898            // before any gender dispatch, so `_money_verbose(0, ...)` gets
899            // "dim" either way.
900            return Ok(CARDINAL_WORDS[0].to_vec());
901        }
902        // Mirrors Python's `elif not number < 999 * 10**33` verbatim. Kept in
903        // the negated form rather than the "minimal" `>=` so the correspondence
904        // to the source line stays greppable; the two are equivalent for BigInt.
905        #[allow(clippy::nonminimal_bool)]
906        if !(number < maxval_cy()) {
907            // NotImplementedError, *not* the OverflowError that
908            // Num2Word_Base.to_cardinal would raise — CY never reaches base.
909            return Err(N2WError::NotImplemented(
910                "The given number is too large.".to_string(),
911            ));
912        }
913
914        // Split into groups of three digits, from the right. Python iterates
915        // pot ascending, so `lowestgroup` ends up holding the *value* of the
916        // lowest non-zero group (module bug 6: a value, not a position).
917        let mut groups: Vec<(i64, u32)> = Vec::new();
918        let mut lowestgroup: Option<i64> = None;
919        for pot in [3u32, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36] {
920            let hi = BigInt::from(10u32).pow(pot);
921            let lo = BigInt::from(10u32).pow(pot - 3);
922            // Both operands are non-negative here (the sign was stripped
923            // above), so Python's floor semantics and Rust's truncation agree;
924            // div_floor is used anyway to keep the correspondence exact.
925            let gr = number.mod_floor(&hi).div_floor(&lo);
926            // gr < 1000 by construction, so the i64 cast is provably lossless.
927            let gr = gr.to_i64().expect("group of three digits fits i64");
928            groups.push((gr, pot));
929            // Python: `if gr and not lowestgroup` — note `not lowestgroup` is
930            // falsy for 0 too, but a stored lowestgroup is always non-zero
931            // (it is only assigned under `if gr`), so `is_none()` is exact.
932            if gr != 0 && lowestgroup.is_none() {
933                lowestgroup = Some(gr);
934            }
935        }
936
937        let mut result: Vec<Entry> = Vec::new();
938        if negative {
939            // MINUS_PREFIX_WORD is "meinws " but the word list hardcodes the
940            // unspaced form; makestring supplies the join.
941            result.push(("meinws", None));
942        }
943
944        for &(gr, pot) in groups.iter().rev() {
945            if gr == 0 {
946                continue;
947            }
948            let g = if pot == 6 {
949                "fem" // mil (1000) is feminine
950            } else if pot == 3 {
951                gender // units depend on the following noun
952            } else {
953                "masc" // millions etc are masculine
954            };
955            // gr == 1 above the units group emits only the magnitude word:
956            // 1000 is "mil", not "un mil"; 10**6 is "miliwn".
957            if gr > 1 || pot == 3 {
958                let words = hundred_group(gr, informal, g, ordinal && lowestgroup == Some(gr));
959                result.extend(words);
960            }
961            if pot > 3 {
962                result.push(million_word(pot - 3));
963            }
964        }
965
966        // Python branches on `number < 100` here (the *absolute* value — the
967        // sign was stripped in place):
968        //
969        //     if number < 100:  return makestring(result, counted=counted)
970        //     else:             if counted: result.extend([("o", "SM"),
971        //                                                  (counted, None)])
972        //                       return makestring(result)
973        //
974        // The two arms differ only in where a `counted` noun lands: inside the
975        // word list via the first OBJ, or appended as "o <noun>" ("of nouns",
976        // the >99 partitive). `counted` is always None in the four in-scope
977        // modes, so both arms collapse to the same call and the branch is
978        // elided rather than duplicated.
979        //
980        // Under `raw=True` Python returns `result` *before* either arm, which
981        // is where this function stops. `_money_verbose` then re-implements the
982        // same "o <plural>" partitive by hand — see [`LangCy::money_verbose`].
983        Ok(result)
984    }
985
986    /// Python's `Num2Word_CY.to_cardinal(number, informal, gender, ordinal,
987    /// counted, raw)` with `raw=False`, `counted=None`.
988    ///
989    /// `counted` is out of scope (the dispatcher's Rust fast path only fires
990    /// with no kwargs), so Python's `number < 100` branch collapses — see
991    /// [`LangCy::to_cardinal_raw`]'s tail comment.
992    fn to_cardinal_full(
993        &self,
994        number: &BigInt,
995        informal: bool,
996        gender: &str,
997        ordinal: bool,
998    ) -> Result<String> {
999        let words = self.to_cardinal_raw(number, informal, gender, ordinal)?;
1000        Ok(makestring(&words, None))
1001    }
1002
1003    /// Python's `Num2Word_CY.float_to_words`, reached from
1004    /// `to_cardinal(<float>)` (the `isinstance(number, float)` guard).
1005    ///
1006    /// ```python
1007    /// def float_to_words(self, float_number):
1008    ///     is_negative = float_number < 0
1009    ///     abs_float = abs(float_number)
1010    ///     prefix = self.to_cardinal(int(abs_float))
1011    ///     float_part = str(abs_float).split(".")[1]
1012    ///     postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])
1013    ///     result = prefix + Num2Word_CY.FLOAT_INFIX_WORD + postfix
1014    ///     if is_negative:
1015    ///         result = "meinws " + result
1016    ///     return result
1017    /// ```
1018    ///
1019    /// This deliberately does **not** touch `base.float2tuple`: CY reads the
1020    /// fractional digits straight out of `str(abs_float)`, one character at a
1021    /// time. There is no `< 0.01` artefact heuristic and no banker's rounding —
1022    /// `2.675` renders `"chwech saith pump"` (6 7 5) because `str(2.675)` is
1023    /// literally `"2.675"`, not because the f64 was repaired.
1024    ///
1025    /// `precision` is the repr-derived fractional-digit count the shim computed
1026    /// (`abs(Decimal(str(value)).as_tuple().exponent)`). In the fixed-notation
1027    /// range it equals `len(str(abs_float).split(".")[1])`, so formatting
1028    /// `abs_float` to that many places reproduces the exact repr digits
1029    /// (verified byte-for-byte against the live interpreter over the corpus
1030    /// float set). Exponent-notation floats (`>= 1e16`, `< 1e-4`) instead take
1031    /// [`py_float_str`]'s e-form arm: `str(1e16)` is "1e+16", which has no '.'
1032    /// and raises **IndexError** on the split, while "1.5e+16" splits into a
1033    /// float_part of "5e+16" whose 'e' raises **ValueError** at `int(c)`.
1034    ///
1035    /// Note the order: `prefix = self.to_cardinal(int(abs_float))` runs
1036    /// *before* the split, so a float at/above `999 * 10**33` raises
1037    /// NotImplementedError first.
1038    ///
1039    /// `precision_override` is ignored, exactly as Python ignores it here: CY's
1040    /// `pass` `__init__` never creates `self.precision`, so the dispatcher's
1041    /// `hasattr(converter, "precision")` guard is false and the kwarg is dropped
1042    /// (confirmed live: `num2words(2.675, lang="cy", precision=1)` is unchanged).
1043    fn float_to_words(&self, value: f64, precision: u32) -> Result<String> {
1044        // Python: `is_negative = float_number < 0` — the *value*, not the sign
1045        // bit, so -0.0 is NOT negative here ("dim pwynt dim", no meinws).
1046        let is_negative = value < 0.0;
1047        let abs_float = value.abs();
1048
1049        // prefix = self.to_cardinal(int(abs_float)) — int() truncates toward
1050        // zero; a whole f64 converts exactly at any magnitude.
1051        let pre = BigInt::from_f64(abs_float.trunc()).expect("finite float truncates exactly");
1052        let prefix = self.to_cardinal_full(&pre, false, "masc", false)?;
1053
1054        // float_part = str(abs_float).split(".")[1]
1055        let s = py_float_str(abs_float, precision);
1056        let float_part = match s.split_once('.') {
1057            Some((_, frac)) => frac.to_string(),
1058            // repr picked exponent form with an integral mantissa ("1e+16"):
1059            // no '.' → Python's [1] raises IndexError.
1060            None => return Err(N2WError::Index("list index out of range".into())),
1061        };
1062
1063        // postfix = " ".join(to_cardinal(int(c)) for c in float_part)
1064        let mut parts: Vec<String> = Vec::new();
1065        for c in float_part.chars() {
1066            // Python `int(c)`: a non-digit ('e' from "1.5e+16") raises
1067            // ValueError with exactly this message.
1068            let d = c.to_digit(10).ok_or_else(|| {
1069                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
1070            })?;
1071            parts.push(self.to_cardinal_full(&BigInt::from(d), false, "masc", false)?);
1072        }
1073        let postfix = parts.join(" ");
1074
1075        // result = prefix + FLOAT_INFIX_WORD (" pwynt ") + postfix.
1076        let result = format!("{} pwynt {}", prefix, postfix);
1077        // The negative prefix is the literal "meinws " prepended to the whole
1078        // string (float_to_words line 251), not routed through the word list.
1079        if is_negative {
1080            Ok(format!("meinws {}", result))
1081        } else {
1082            Ok(result)
1083        }
1084    }
1085
1086    /// Python's `Num2Word_CY.to_cardinal(<Decimal>)`.
1087    ///
1088    /// A `Decimal` is **not** a `float`, so the `isinstance(number, float)`
1089    /// guard is false and `float_to_words` is *not* used. The Decimal instead
1090    /// flows through the ordinary integer branch of `to_cardinal`, where the
1091    /// fractional part is floored away group-by-group
1092    /// (`(number % 10**pot) // 10**(pot-3)`), yet the sign, zero, ceiling and
1093    /// `< 100` tests all read the FULL Decimal value.
1094    ///
1095    /// The observable consequence is that a purely fractional Decimal renders as
1096    /// the empty string, not `"dim"`: `Decimal("0.5") == 0` is false, so the
1097    /// `"dim"` short-circuit is skipped and every digit group is zero, leaving
1098    /// an empty word list. Only an exact-zero Decimal yields `"dim"`. Negatives
1099    /// keep their `("meinws", None)` prefix even when the integer part is zero:
1100    /// `Decimal("-0.5")` is `"meinws"`.
1101    ///
1102    /// `ordinal` is `to_cardinal(..., ordinal=True)` — live when `to_ordinal`
1103    /// delegates a Decimal in 20..100 here (`to_ordinal(Decimal('42'))` →
1104    /// "ail a deugain"). `lowestgroup` then matters; it holds the *value* of
1105    /// the lowest non-zero group, exactly like the int path (module bug 6).
1106    fn decimal_to_cardinal(&self, value: &BigDecimal, ordinal: bool) -> Result<String> {
1107        let negative = value.is_negative();
1108        let absval = value.abs();
1109
1110        // `if number == 0`: the full Decimal. Exact zero → "dim"; a fractional
1111        // value falls through to the (all-zero) group split → empty string.
1112        if absval.is_zero() {
1113            return Ok(makestring(CARDINAL_WORDS[0], None));
1114        }
1115        // `elif not number < 999 * 10**33`, on the full Decimal.
1116        #[allow(clippy::nonminimal_bool)]
1117        if !(absval < BigDecimal::from(maxval_cy())) {
1118            return Err(N2WError::NotImplemented(
1119                "The given number is too large.".to_string(),
1120            ));
1121        }
1122
1123        // `(number % 10**pot) // 10**(pot-3)` on the Decimal equals the same
1124        // digit groups of the truncated integer part — the `//` floors the
1125        // fraction away (verified against the live Decimal arithmetic). So take
1126        // the groups from `pre = int(abs)` and reuse the integer machinery.
1127        let pre = absval.with_scale(0).as_bigint_and_exponent().0; // trunc toward zero (>= 0)
1128
1129        let mut groups: Vec<(i64, u32)> = Vec::new();
1130        let mut lowestgroup: Option<i64> = None;
1131        for pot in [3u32, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36] {
1132            let hi = BigInt::from(10u32).pow(pot);
1133            let lo = BigInt::from(10u32).pow(pot - 3);
1134            let gr = pre.mod_floor(&hi).div_floor(&lo);
1135            let gr = gr.to_i64().expect("group of three digits fits i64");
1136            groups.push((gr, pot));
1137            if gr != 0 && lowestgroup.is_none() {
1138                lowestgroup = Some(gr);
1139            }
1140        }
1141
1142        let mut result: Vec<Entry> = Vec::new();
1143        if negative {
1144            result.push(("meinws", None));
1145        }
1146        for &(gr, pot) in groups.iter().rev() {
1147            if gr == 0 {
1148                continue;
1149            }
1150            // g = gender ("masc") for pot==3, "fem" for pot==6 (mil), else
1151            // "masc" — pot==3 and the else-arm coincide at the default gender.
1152            let g = if pot == 6 { "fem" } else { "masc" };
1153            if gr > 1 || pot == 3 {
1154                let words = hundred_group(gr, false, g, ordinal && lowestgroup == Some(gr));
1155                result.extend(words);
1156            }
1157            if pot > 3 {
1158                result.push(million_word(pot - 3));
1159            }
1160        }
1161
1162        // Python's `number < 100` branch differs only in `counted` placement,
1163        // which is None here, so both arms collapse to `makestring(result)`.
1164        Ok(makestring(&result, None))
1165    }
1166
1167    /// Python's `Num2Word_CY.to_ordinal(number, informal=False, gender="masc")`
1168    /// with the gender threaded through — shared by the plain hook (masc) and
1169    /// [`Lang::to_ordinal_kw`].
1170    ///
1171    /// Only 20..=99 ever reaches `to_cardinal(..., ordinal=True)`: `< 20` is
1172    /// table-driven (and ignores `gender` — ORDINAL_WORDS is indexed
1173    /// directly, so `to_ordinal(2, gender="fem")` is "ail", not the dead
1174    /// ORDINAL_WORDS_FEM's entry), `== 100` is the literal "canfed", and
1175    /// `> 100` raises. That range is a single three-digit group, which is why
1176    /// module bug 6 (`lowestgroup` comparing values) can never fire in scope.
1177    fn to_ordinal_impl(&self, value: &BigInt, gender: &str) -> Result<String> {
1178        if value < &BigInt::from(20) {
1179            // Python: ORDINAL_WORDS[number]. Every negative satisfies `< 20`
1180            // and misses the dict → KeyError. Module bug 7.
1181            if value.is_negative() {
1182                return Err(N2WError::Key(format!("{}", value)));
1183            }
1184            // 0..=19 — always present.
1185            let idx = value.to_usize().expect("0..=19 fits usize");
1186            return Ok(makestring(ORDINAL_WORDS[idx], None));
1187        }
1188        if value == &BigInt::from(100) {
1189            return Ok("canfed".to_string());
1190        } else if value > &BigInt::from(100) {
1191            return Err(N2WError::NotImplemented(
1192                "The given number is too large.".to_string(),
1193            ));
1194        }
1195        // Python passes informal=False literally here, discarding its own
1196        // `informal` parameter. Reproduced.
1197        self.to_cardinal_full(value, false, gender, true)
1198    }
1199}
1200
1201impl Lang for LangCy {
1202    /// This language's own `to_currency(currency=...)` default,
1203    /// read from the live Python signature. Only 44 of 156 use EUR.
1204    fn default_currency(&self) -> &str {
1205        "GBP"
1206    }
1207
1208    /// This language's own `to_currency(separator=...)` default,
1209    /// read from the live Python signature. Base's is ",", but only
1210    /// 36 of 149 languages actually use it — most default to " " or a
1211    /// conjunction, so inheriting Base's comma silently corrupts them.
1212    fn default_separator(&self) -> &str {
1213        ","
1214    }
1215
1216    // cards() / maxval() / merge() stay at their trait defaults: Python never
1217    // builds self.cards for CY (see the module docs on the `pass` __init__),
1218    // so splitnum/clean/merge are unreachable.
1219
1220    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1221        // Dispatcher calls to_cardinal(number) — all kwargs at their defaults.
1222        self.to_cardinal_full(value, false, "masc", false)
1223    }
1224
1225    /// Python's `Num2Word_CY.to_ordinal(number, informal=False, gender="masc")`
1226    /// at its kwarg defaults — see [`LangCy::to_ordinal_impl`].
1227    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1228        self.to_ordinal_impl(value, "masc")
1229    }
1230
1231    // to_ordinal_num: inherited from Num2Word_Base, which returns the value
1232    // itself. The trait default's `Ok(value.to_string())` matches the corpus.
1233
1234    // to_year: inherited from Num2Word_Base — `self.to_cardinal(value)`. The
1235    // trait default delegates through &self and picks up the override above.
1236
1237    /// The float/Decimal cardinal path.
1238    ///
1239    /// CY does **not** override `to_cardinal_float` in Python — and it must not
1240    /// use the trait default here either. Python routes both float and Decimal
1241    /// input through `Num2Word_CY.to_cardinal(number)` (the dispatcher calls
1242    /// `converter.to_cardinal(number)` for `to="cardinal"`), where CY's own
1243    /// override branches on `isinstance(number, float)`:
1244    ///
1245    ///   * a `float` → [`LangCy::float_to_words`] — a bespoke path that reads the
1246    ///     fractional digits straight out of `str(abs_float).split(".")[1]` and
1247    ///     joins them with `" pwynt "`. It never touches `base.float2tuple`, so
1248    ///     there is no `< 0.01` artefact heuristic and no banker's rounding.
1249    ///   * a `Decimal` (not a `float`) → the ordinary integer branch, which
1250    ///     floors the fraction away group-by-group → [`LangCy::decimal_to_cardinal`].
1251    ///
1252    /// The base default `default_to_cardinal_float` would instead run
1253    /// `float2tuple` + `self.pointword`, and CY's `pass` `__init__` never assigns
1254    /// `pointword` — so the default is both semantically wrong (no `" pwynt "`
1255    /// infix) and would crash on the missing attribute. Hence this override.
1256    ///
1257    /// `precision_override` is dropped, exactly as Python drops it: the
1258    /// dispatcher only applies `precision=` when `hasattr(converter, "precision")`,
1259    /// and CY's `pass` `__init__` never creates `self.precision`, so the kwarg
1260    /// never reaches the conversion (confirmed live: `num2words(2.675, lang="cy",
1261    /// precision=1)` is unchanged). The repr-derived precision the shim already
1262    /// put in `FloatValue::Float` is what `float_to_words` uses.
1263    fn to_cardinal_float(
1264        &self,
1265        value: &FloatValue,
1266        _precision_override: Option<u32>,
1267    ) -> Result<String> {
1268        match value {
1269            FloatValue::Float { value, precision } => self.float_to_words(*value, *precision),
1270            FloatValue::Decimal { value, .. } => self.decimal_to_cardinal(value, false),
1271        }
1272    }
1273
1274    /// `to_cardinal(float/Decimal)` — the FULL entry, whole values included.
1275    ///
1276    /// The base default's whole-value shortcut (`1.0` → integer path → "un")
1277    /// must not fire: CY's `isinstance(number, float)` guard routes *every*
1278    /// float through `float_to_words`, so `to_cardinal(1.0)` is
1279    /// "un pwynt dim", and a whole Decimal takes the integer branch (where it
1280    /// happens to equal the int rendering). Both live in
1281    /// [`LangCy::to_cardinal_float`]'s routing, so delegate wholesale.
1282    fn cardinal_float_entry(
1283        &self,
1284        value: &FloatValue,
1285        precision_override: Option<u32>,
1286    ) -> Result<String> {
1287        self.to_cardinal_float(value, precision_override)
1288    }
1289
1290    // year_float_entry: the trait default delegates to cardinal_float_entry,
1291    // which is exactly Python (`to_year` is Base's `self.to_cardinal(value)`),
1292    // so `to_year(1.0)` is "un pwynt dim" and `to_year(1e16)` IndexErrors.
1293
1294
1295    /// `to_ordinal(float/Decimal)` — Python's dict-lookup gauntlet.
1296    ///
1297    /// * `number < 20` → `ORDINAL_WORDS[number]`. The lookup succeeds iff the
1298    ///   value hash-equals an int key 0..=19 (`5.0`, `Decimal('5.00')`,
1299    ///   `-0.0`); anything else — fractional, or negative — raises
1300    ///   **KeyError** (module bug 7 extended to the float domain).
1301    /// * `number == 100` → "canfed" (`1E+2` too); `> 100` →
1302    ///   **NotImplementedError**.
1303    /// * else (20 ≤ n < 100) → `to_cardinal(number, ordinal=True)`: a float
1304    ///   hits the isinstance guard first and renders as a plain **cardinal**
1305    ///   ("ugain pwynt dim"); a Decimal reaches the integer branch with
1306    ///   `ordinal=True` live ("ail a deugain" for `Decimal('42')`).
1307    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1308        // `if number < 20:`
1309        let lt20 = match value {
1310            FloatValue::Float { value, .. } => *value < 20.0,
1311            FloatValue::Decimal { value, .. } => *value < BigDecimal::from(20),
1312        };
1313        if lt20 {
1314            if let Some(i) = value.as_whole_int() {
1315                // -0.0 == 0 hits the key; true negatives miss it.
1316                if !i.is_negative() {
1317                    let idx = i.to_usize().expect("0..=19 fits usize");
1318                    return Ok(makestring(ORDINAL_WORDS[idx], None));
1319                }
1320            }
1321            return Err(N2WError::Key(float_key_repr(value)));
1322        }
1323        let (eq100, gt100) = match value {
1324            FloatValue::Float { value, .. } => (*value == 100.0, *value > 100.0),
1325            FloatValue::Decimal { value, .. } => {
1326                let hundred = BigDecimal::from(100);
1327                (*value == hundred, *value > hundred)
1328            }
1329        };
1330        if eq100 {
1331            return Ok("canfed".to_string());
1332        }
1333        if gt100 {
1334            return Err(N2WError::NotImplemented(
1335                "The given number is too large.".to_string(),
1336            ));
1337        }
1338        // return self.to_cardinal(number, informal=False, gender=gender,
1339        //                         ordinal=True)
1340        match value {
1341            FloatValue::Float { value, precision } => self.float_to_words(*value, *precision),
1342            FloatValue::Decimal { value, .. } => self.decimal_to_cardinal(value, true),
1343        }
1344    }
1345
1346    /// `converter.str_to_number` — Base's `Decimal(value)`. Inf/NaN parse
1347    /// fine here and blow up only later inside `to_cardinal`; the binding
1348    /// routes `ParsedNumber::Inf`/`NaN` to [`Lang::inf_result`] /
1349    /// [`Lang::nan_result`] below, which raise CY's exact exception types
1350    /// natively (no Python fallback).
1351    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1352        python_decimal_parse(s)
1353    }
1354
1355    /// `Decimal('Infinity')` / `Decimal('-Infinity')`. CY's `to_cardinal`
1356    /// never reaches Base's `int(Decimal Inf)` (which OverflowErrors): the
1357    /// `not number < 999 * 10**33` ceiling fires first —
1358    /// `Decimal('Infinity') < 999*10**33` is False for both signs (the `-`
1359    /// case flips to `+Infinity` via `number = -number` before the ceiling),
1360    /// so it raises **NotImplementedError**. `to_ordinal`/`to_year` reach the
1361    /// same raise. `to_ordinal_num` would echo the repr, but no corpus row
1362    /// exercises it.
1363    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
1364        match to {
1365            "ordinal_num" => Ok(format!(
1366                "{}Infinity",
1367                if negative { "-" } else { "" }
1368            )),
1369            _ => Err(N2WError::NotImplemented(
1370                "The given number is too large.".to_string(),
1371            )),
1372        }
1373    }
1374
1375    /// `Decimal('NaN')`. CY's `to_cardinal` does `number < 0` on it, and a
1376    /// `Decimal('NaN')` comparison raises **decimal.InvalidOperation** before
1377    /// any rendering. Surfaced as the same class the binding re-raises.
1378    fn nan_result(&self, to: &str) -> Result<String> {
1379        match to {
1380            "ordinal_num" => Ok("NaN".to_string()),
1381            _ => Err(N2WError::Custom {
1382                module: "decimal",
1383                class: "InvalidOperation",
1384                msg: "[<class 'decimal.InvalidOperation'>]".to_string(),
1385            }),
1386        }
1387    }
1388
1389    /// `Decimal('-0.0')`. Unlike float `-0.0` (which CY's
1390    /// `isinstance(number, float)` guard sends through `float_to_words` →
1391    /// "dim pwynt dim"), a `Decimal('-0.0')` is not a `float`: `number == 0`
1392    /// is True, so `to_cardinal` short-circuits to the integer zero "dim".
1393    /// `to_year` is `to_cardinal`, so "dim" as well. `to_ordinal` (dict key
1394    /// 0 → "dimfed") and `to_ordinal_num` (echoes "-0.0") coincide with the
1395    /// float `-0.0` demotion, so they return `None` (the default path).
1396    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
1397        match to {
1398            "cardinal" | "year" => Some(Ok("dim".to_string())),
1399            _ => None,
1400        }
1401    }
1402
1403    /// `Num2Word_Base.to_fraction`, restated locally because its negative
1404    /// branch reads `self.negword` — which CY's `pass` `__init__` never
1405    /// created, so **every negative fraction raises AttributeError**
1406    /// (`-1/2`, `1/-2`; `-3/-4` is positive and renders "tri pedwerydds").
1407    /// The sign string is built before the numerator/denominator words, so
1408    /// the AttributeError beats the KeyError/NotImplementedError that
1409    /// `to_ordinal(<big/negative>)` would otherwise raise.
1410    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
1411        if denominator.is_zero() {
1412            return Err(N2WError::ZeroDivision(
1413                "denominator must not be zero".into(),
1414            ));
1415        }
1416        if denominator.is_one() || numerator.is_zero() {
1417            return self.to_cardinal(numerator);
1418        }
1419        let is_negative = numerator.is_negative() ^ denominator.is_negative();
1420        if is_negative {
1421            // sign = "%s " % self.negword.strip() → missing attribute.
1422            return Err(N2WError::Attribute(
1423                "'Num2Word_CY' object has no attribute 'negword'".into(),
1424            ));
1425        }
1426        let abs_n = numerator.abs();
1427        let abs_d = denominator.abs();
1428        let num_word = self.to_cardinal(&abs_n)?;
1429        let mut den_word = self.to_ordinal(&abs_d)?;
1430        if !abs_n.is_one() {
1431            // Base's naive plural: "s" tacked on — "tri pedwerydds".
1432            den_word.push('s');
1433        }
1434        Ok(format!("{} {}", num_word, den_word))
1435    }
1436
1437    // ---- grammatical kwargs ---------------------------------------------
1438
1439    /// `to_cardinal(number, informal=False, gender="masc", ordinal=False,
1440    /// counted=None, raw=False)`.
1441    ///
1442    /// `ordinal=` in the bag is the *dispatcher's* named parameter: `num2words`
1443    /// consumes it before any converter method is chosen and a truthy value
1444    /// rewrites `to = "ordinal"`, so `num2words(n, lang="cy", ordinal=True,
1445    /// gender=...)` is `converter.to_ordinal(n, gender=...)` — never CY's own
1446    /// `to_cardinal(..., ordinal=True)` (which has no <20/100 gates and picks
1447    /// CARDINAL_WORDS for small values). The corpus pins the dispatcher
1448    /// behaviour, so a truthy `ordinal` reroutes to [`Lang::to_ordinal_kw`]
1449    /// with the remaining kwargs; a falsy one is dropped, exactly as the
1450    /// dispatcher never forwards it.
1451    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1452        if kw.is_empty() {
1453            return self.to_cardinal(value);
1454        }
1455        if !kw.only(&["informal", "gender", "counted", "raw", "ordinal"]) {
1456            return Err(N2WError::Fallback("kwargs".into()));
1457        }
1458        if let Some(v) = kw.get("ordinal") {
1459            if kw_truthy(v) {
1460                // to = "ordinal": the rest of the bag rides to_ordinal. A
1461                // leftover counted=/raw= makes to_ordinal_kw decline, and the
1462                // Python original then raises its own TypeError.
1463                let rest: Vec<(String, KwVal)> = kw
1464                    .0
1465                    .iter()
1466                    .filter(|(k, _)| k != "ordinal")
1467                    .cloned()
1468                    .collect();
1469                return self.to_ordinal_kw(value, &Kwargs(rest));
1470            }
1471            // Falsy ordinal= keeps to="cardinal" and is simply not forwarded.
1472        }
1473        // informal: threaded through and never read (its only use site is
1474        // commented out). Any value accepted, any value ignored.
1475        let raw = kw.get("raw").map(kw_truthy).unwrap_or(false);
1476        let counted: Option<&str> = match kw.get("counted") {
1477            // counted=None is the signature default.
1478            None | Some(KwVal::None) => None,
1479            Some(KwVal::Str(s)) => Some(s.as_str()),
1480            // A non-str counted would crash inside softmutation's word[0]
1481            // arithmetic in ways not worth modelling — let Python own it.
1482            Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1483        };
1484        // gender only ever matters as the literal `== "fem"` comparison:
1485        // "f", "m", None, 1 ... all behave as masculine.
1486        let gender = if kw.str("gender") == Some("fem") {
1487            "fem"
1488        } else {
1489            "masc"
1490        };
1491
1492        let words = self.to_cardinal_raw(value, false, gender, false)?;
1493        if raw {
1494            // Python returns the list object; the corpus stringifies it.
1495            return Ok(py_repr_wordlist(&words));
1496        }
1497        if value.is_zero() {
1498            // Python's zero guard returns before `counted` is consulted:
1499            // to_cardinal(0, counted="ci") is plain "dim".
1500            return Ok(makestring(&words, None));
1501        }
1502        if value.abs() < BigInt::from(100) {
1503            // `counted` fills the first OBJ, landing *inside* the numeral:
1504            // "un ci ar hugain".
1505            let w: Vec<Word<'_>> = words.iter().map(|&(a, b)| (a, b)).collect();
1506            Ok(makestring(&w, counted))
1507        } else {
1508            // The >= 100 partitive: "o <noun>", with "o" soft-mutating the
1509            // noun ("cant o gi").
1510            let mut w: Vec<Word<'_>> = words.iter().map(|&(a, b)| (a, b)).collect();
1511            if let Some(c) = counted {
1512                w.push(("o", Some(SM)));
1513                w.push((c, None));
1514            }
1515            Ok(makestring(&w, None))
1516        }
1517    }
1518
1519    /// `to_ordinal(number, informal=False, gender="masc")`.
1520    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1521        if !kw.only(&["informal", "gender"]) {
1522            return Err(N2WError::Fallback("kwargs".into()));
1523        }
1524        let gender = if kw.str("gender") == Some("fem") {
1525            "fem"
1526        } else {
1527            "masc"
1528        };
1529        self.to_ordinal_impl(value, gender)
1530    }
1531
1532    /// `Num2Word_Base.to_year(value, **kwargs)` — the `**kwargs` swallows
1533    /// every keyword unread, so any bag is accepted and the cardinal is
1534    /// returned.
1535    fn to_year_kw(&self, value: &BigInt, _kw: &Kwargs) -> Result<String> {
1536        self.to_cardinal(value)
1537    }
1538
1539    // to_ordinal_num_kw / to_currency_kw: neither Python method takes extra
1540    // kwargs, so the trait defaults (empty bag or Python's own TypeError via
1541    // fallback) are already exact.
1542
1543    /// `to_cardinal(<float/Decimal>, **kwargs)`. A float hits the
1544    /// `isinstance(number, float)` guard before any kwarg is read, so
1545    /// informal/gender/counted/raw are all silently ignored and
1546    /// `float_to_words` renders as usual. A Decimal reaches the integer
1547    /// branch with the kwargs live — no corpus coverage, left to the Python
1548    /// fallback, which owns it.
1549    fn to_cardinal_float_kw(
1550        &self,
1551        value: &FloatValue,
1552        precision_override: Option<u32>,
1553        kw: &Kwargs,
1554    ) -> Result<String> {
1555        if kw.is_empty() {
1556            return self.cardinal_float_entry(value, precision_override);
1557        }
1558        if !kw.only(&["informal", "gender", "counted", "raw"]) {
1559            return Err(N2WError::Fallback("kwargs".into()));
1560        }
1561        match value {
1562            FloatValue::Float { value, precision } => self.float_to_words(*value, *precision),
1563            FloatValue::Decimal { .. } => Err(N2WError::Fallback("kwargs".into())),
1564        }
1565    }
1566
1567    // ---- currency -------------------------------------------------------
1568    //
1569    // CY overrides `to_currency`, `_money_verbose` and `_cents_verbose`.
1570    // `pluralize` comes from Num2Word_EUR; `to_cheque` and `_cents_terse` from
1571    // Num2Word_Base — the trait defaults already mirror both, so they are not
1572    // restated here. `currency_precision` likewise stays at its default 100:
1573    // CY's CURRENCY_PRECISION is Base's empty dict, so `.get(code, 100)` is
1574    // always 100.
1575
1576    fn lang_name(&self) -> &str {
1577        "Num2Word_CY"
1578    }
1579
1580    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1581        self.currency_forms.get(code)
1582    }
1583
1584    fn currency_adjective(&self, code: &str) -> Option<&str> {
1585        self.currency_adjectives.get(code).copied()
1586    }
1587
1588    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
1589    ///
1590    /// Python indexes the tuple directly, so a one-form entry with `n != 1`
1591    /// would raise IndexError. All four CY entries carry two forms, so that is
1592    /// unreachable — mapped to `Index` rather than panicking so the exception
1593    /// type survives if the table ever changes.
1594    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1595        let form = if n.is_one() { 0 } else { 1 };
1596        forms
1597            .get(form)
1598            .cloned()
1599            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1600    }
1601
1602    /// Python's `Num2Word_CY._money_verbose`.
1603    ///
1604    /// Both arms ask `to_cardinal` for the **feminine** form unconditionally
1605    /// (module bug 12) and both attach the currency noun themselves, on top of
1606    /// the `pluralize(left, cr1)` Base appends afterwards (bug 13):
1607    ///
1608    /// * `number > 100` — the partitive: `... o <plural>`. "o" triggers a soft
1609    ///   mutation on the noun, so `1234.56 USD` gives "o **dd**olarau".
1610    /// * otherwise — the noun goes in as `counted`, landing at the first `OBJ`
1611    ///   and so *inside* the numeral ("pedwar **ceiniog** ar ddeg ar hugain").
1612    ///
1613    /// Note `> 100`, not `>= 100`: exactly 100 takes the `counted` arm.
1614    fn money_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
1615        // Python indexes `self.CURRENCY_FORMS[currency]` bare here — a KeyError
1616        // if missing. Unreachable in practice: to_currency and to_cheque both
1617        // resolve the code (raising NotImplementedError) before calling this.
1618        let forms = self
1619            .currency_forms
1620            .get(currency)
1621            .ok_or_else(|| N2WError::Key(format!("'{}'", currency)))?;
1622
1623        let raw = self.to_cardinal_raw(number, false, "fem", false)?;
1624        let mut m: Vec<Word<'_>> = raw.iter().map(|&(w, mu)| (w, mu)).collect();
1625
1626        if number > &BigInt::from(100) {
1627            // CURRENCY_FORMS[currency][0][1] — the plural unit.
1628            let c = &forms.unit[1];
1629            m.push(("o", Some(SM)));
1630            m.push((c.as_str(), None));
1631            Ok(makestring(&m, None))
1632        } else {
1633            // CURRENCY_FORMS[currency][0][0] — the singular unit.
1634            let c = &forms.unit[0];
1635            Ok(makestring(&m, Some(c.as_str())))
1636        }
1637    }
1638
1639    /// Python's `Num2Word_CY._cents_verbose`.
1640    ///
1641    /// Three quirks, all ported: zero cents return `""` (bug 14's source);
1642    /// `number == 1` drops the numeral entirely and emits only the noun
1643    /// (bug 11); and gender is left at its masculine default, unlike
1644    /// [`LangCy::money_verbose`].
1645    fn cents_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
1646        // Python returns "" before it ever indexes CURRENCY_FORMS, so a missing
1647        // code would not KeyError at zero. Ordered the same way here.
1648        if number.is_zero() {
1649            return Ok(String::new());
1650        }
1651        let forms = self
1652            .currency_forms
1653            .get(currency)
1654            .ok_or_else(|| N2WError::Key(format!("'{}'", currency)))?;
1655
1656        // `if number > 1: to_cardinal(number, raw=True) else: [(OBJ, None)]`.
1657        // The else arm also catches a hypothetical negative, which cannot occur:
1658        // Base hands cents through `abs()`.
1659        let m: Vec<Word<'_>> = if number > &BigInt::one() {
1660            let raw = self.to_cardinal_raw(number, false, "masc", false)?;
1661            raw.iter().map(|&(w, mu)| (w, mu)).collect()
1662        } else {
1663            vec![(OBJ, None)]
1664        };
1665
1666        // CURRENCY_FORMS[currency][1][0] — the singular subunit.
1667        let c = &forms.subunit[0];
1668        Ok(makestring(&m, Some(c.as_str())))
1669    }
1670
1671    /// Python's `Num2Word_CY.to_currency`.
1672    ///
1673    /// ```python
1674    /// def to_currency(self, val, currency="GBP", cents=True,
1675    ///                 separator=",", adjective=False):
1676    ///     if isinstance(val, int):
1677    ///         minus_str = "minws " if val < 0 else ""
1678    ///         money_str = self.to_cardinal(abs(val))
1679    ///         if abs(val) == 1: currency_str = "bunt"
1680    ///         else:             currency_str = "bunnoedd"
1681    ///         return "%s%s %s" % (minus_str, money_str, currency_str)
1682    ///     return super().to_currency(val, currency=currency, cents=cents,
1683    ///                                separator=separator, adjective=adjective)
1684    /// ```
1685    ///
1686    /// The int arm never reads `currency`, `cents`, `separator` or `adjective`,
1687    /// and never touches `CURRENCY_FORMS` — hence module bugs 8, 9 and 10.
1688    fn to_currency(
1689        &self,
1690        val: &CurrencyValue,
1691        currency: &str,
1692        cents: bool,
1693        separator: Option<&str>,
1694        adjective: bool,
1695    ) -> Result<String> {
1696        // The trait hands us None when the caller omitted `separator=`; resolve
1697        // it to CY's own default (",") before the ported body.
1698        let separator = separator.unwrap_or(self.default_separator());
1699
1700        if let CurrencyValue::Int(v) = val {
1701            // "minws ", not CY's own MINUS_PREFIX_WORD "meinws " — bug 9.
1702            let minus_str = if v.is_negative() { "minws " } else { "" };
1703            let abs_val = v.abs();
1704            // Can still raise NotImplementedError past 999 * 10**33.
1705            let money_str = self.to_cardinal(&abs_val)?;
1706            // Pre-mutated GBP literals, whatever `currency` says — bugs 8, 10.
1707            let currency_str = if abs_val.is_one() { "bunt" } else { "bunnoedd" };
1708            return Ok(format!("{}{} {}", minus_str, money_str, currency_str));
1709        }
1710
1711        // Floats/Decimals: `super().to_currency(...)` — Num2Word_Base's.
1712        //
1713        // Base looks CURRENCY_FORMS up *before* it reads `self.negword`, so an
1714        // unknown code raises NotImplementedError even when the value is
1715        // negative and the AttributeError would otherwise fire. That ordering is
1716        // observable — the corpus has `currency:CHF -12.34` as
1717        // NotImplementedError but `currency:EUR -12.34` as AttributeError — so
1718        // the lookup is repeated here ahead of the sign check.
1719        if !self.currency_forms.contains_key(currency) {
1720            return Err(N2WError::NotImplemented(format!(
1721                "Currency code \"{}\" not implemented for \"{}\"",
1722                currency,
1723                self.lang_name()
1724            )));
1725        }
1726
1727        // `minus_str = "%s " % self.negword.strip() if is_negative else ""`.
1728        // CY's `__init__` is a bare `pass`, so `self.negword` was never
1729        // assigned and any negative float dies on attribute lookup. Positives
1730        // are unharmed: Python evaluates the condition first.
1731        //
1732        // Base derives `is_negative` from `parse_currency_parts`, i.e. from the
1733        // ROUND_HALF_UP-quantized value rather than the input. For CY's fixed
1734        // divisor of 100 the two agree on every input — a value can only
1735        // quantize to -0.00 from a value that is already -0.0, which
1736        // `Decimal.__lt__` reports as non-negative anyway. Checked exhaustively
1737        // against the live `parse_currency_parts` over 6020 values, so the raw
1738        // sign is used directly instead of re-running the split.
1739        if val.is_negative() {
1740            return Err(N2WError::Attribute(
1741                "'Num2Word_CY' object has no attribute 'negword'".into(),
1742            ));
1743        }
1744
1745        crate::currency::default_to_currency(self, val, currency, cents, separator, adjective)
1746    }
1747
1748    // cardinal_from_decimal: left at its default — fractional cents are out of
1749    // scope for this phase. See the crate-level note; CY diverges there and it
1750    // is called out in the port report.
1751}