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}