num2words2_core/lang_ky.rs
1//! Port of `lang_KY.py` (Kyrgyz).
2//!
3//! # Deliberate divergences from upstream
4//!
5//! Two, both documented below: the **script** (Cyrillic, not Latin) and the
6//! **ordinal algorithm** (vowel-harmonic suffix, not a glued `-inchi`).
7//! Everything else is ported verbatim, bug for bug.
8//!
9//! ## 1. Cyrillic script
10//!
11//! `lang_KY.py` spells every numeral in **Latin transliteration** ("bir",
12//! "üch", "jıyırma", "jüz"). Kyrgyz is written in Cyrillic — the Latin
13//! alphabet was dropped in 1940 and has no official standing — so the tables
14//! here are the Cyrillic spellings: "бир", "үч", "жыйырма", "жүз".
15//!
16//! The transliteration was not even self-consistent, which is the clearest
17//! evidence it was an accident rather than a choice: `pointword` was the
18//! **mixed-script** string `"üтүр"`, one Latin `ü` (U+00FC) followed by three
19//! Cyrillic letters (U+0442 U+04AF U+0440). Whoever wrote it was typing
20//! Cyrillic and the first keystroke landed in the wrong layout. It is now
21//! plain "үтүр".
22//!
23//! This is a lexicon change only — the composition rules, the `> 1`
24//! multiplier guards, the 10^9 digit cliff and the currency fallback are all
25//! still ported verbatim.
26//!
27//! ## 2. Ordinal algorithm
28//!
29//! Upstream builds every ordinal as `to_cardinal(n) + "-inchi"`, which gives
30//! the non-word "үч-inchi" for 3rd. Kyrgyz forms ordinals with *-(V)нч(V)*,
31//! and **both** vowels agree with the last vowel of the stem — so the one
32//! written suffix is really four, and the linking vowel is dropped after a
33//! stem that already ends in a vowel:
34//!
35//! ```text
36//! бир + инчи -> биринчи алты + нчы -> алтынчы
37//! сегиз + инчи -> сегизинчи жети + нчи -> жетинчи
38//! кырк + ынчы -> кыркынчы элүү + нчү -> элүүнчү
39//! отуз + унчу -> отузунчу жыйырма + нчы -> жыйырманчы
40//! жүз + үнчү -> жүзүнчү үч + үнчү -> үчүнчү
41//! ```
42//!
43//! Only the **last word** takes the ending: 123rd is "жүз жыйырма үчүнчү",
44//! not "жүзүнчү жыйырманчы үчүнчү". See [`ordinal_suffix`] and
45//! [`vowel_class`].
46//!
47//! The **digit** form follows the same harmony: "1-инчи", "3-үнчү",
48//! "10-унчу", "40-ынчы". The ending is read off the spelled cardinal, since
49//! a digit carries no vowel of its own. Kyrgyz orthography rule 88 also
50//! allows the hyphen to stand for the suffix outright when a noun follows
51//! ("16-ноябрь", "4-класс"); that form needs the noun, so it is not what a
52//! standalone `to_ordinal_num` can return. The spelled-out ending after the
53//! hyphen is what the sibling Turkic modules in this crate already do —
54//! `lang_ba.rs` emits "1-се", `lang_az.rs` "1-ci".
55//!
56//! `verify_ordinal` is **not** restored here: upstream lets zero and
57//! negatives through, and this module keeps that, so `to_ordinal(0)` is
58//! "нөлүнчү" and `to_ordinal(-1)` is "минус биринчи". Only the ending
59//! changed.
60//!
61//! Shape: **self-contained**. `Num2Word_KY` subclasses `Num2Word_Base` but
62//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the guard in
63//! `Num2Word_Base.__init__`
64//!
65//! ```text
66//! if any(hasattr(self, field) for field in
67//! ["high_numwords", "mid_numwords", "low_numwords"]):
68//! ```
69//!
70//! is never taken: Python never builds `self.cards` and never sets `MAXVAL`.
71//! `cards`/`maxval`/`merge` therefore stay at their trait defaults here, and
72//! **there is no overflow check at all** — see the `str(number)` fallback
73//! below for what happens past the table's ceiling.
74//!
75//! All four in-scope methods are overridden by `Num2Word_KY`, so nothing from
76//! `Num2Word_Base` reaches the output path:
77//! * `to_cardinal` — own algorithm over `_int_to_word`.
78//! * `to_ordinal` — `to_cardinal(n)` + the harmonised ending.
79//! * `to_ordinal_num` — `str(n)` + "-" + the harmonised ending.
80//! * `to_year` — `to_cardinal(val)`, ignoring its own `longval` arg.
81//!
82//! Two consequences of those overrides are load-bearing:
83//!
84//! 1. `Num2Word_Base.to_cardinal` pipes its result through `self.title()`;
85//! KY's override does not, so titling never applies. (`is_title` is also
86//! left `False` by `setup`, so it would be a no-op regardless.)
87//! 2. `Num2Word_Base.to_ordinal` calls `verify_ordinal`, which raises
88//! `TypeError` for negative input. KY's override skips it, so
89//! `to_ordinal(-1)` == "минус биринчи" rather than raising. Likewise
90//! `to_ordinal(0)` == "нөлүнчү" — no crash.
91//!
92//! # Faithfully reproduced Python quirks
93//!
94//! This is a port, not a rewrite. All of the following are what Python emits
95//! and are verified against the frozen corpus:
96//!
97//! 1. **Numbers >= 10^9 are not spelled at all.** `_int_to_word` handles
98//! units/tens/hundreds/thousands/millions and then falls off the end with
99//! a bare `return str(number)`. There is no milliard/billion entry, and no
100//! `OverflowError` — `to_cardinal(10**9)` == "1000000000" (the digits),
101//! and `to_ordinal(10**9)` == "1000000000-инчи" — the digits have no vowel
102//! to harmonise with, so the default class and the digit form's hyphen
103//! apply. This holds for arbitrary
104//! magnitudes, hence [`LangKy::int_to_word`] takes `&BigInt` and only
105//! narrows to `u64` *after* proving `number < 10^9`.
106//! 2. **"бир миллион" but plain "миң"/"жүз".** The thousands and hundreds
107//! branches suppress the leading "бир" via `if h > 1` / `if t > 1`, so
108//! 100 == "жүз" and 1000 == "миң". The millions branch has no such guard,
109//! so 10^6 == "бир миллион", not "миллион".
110//! 3. **10^9 and up have no word.** There is no "миллиард" entry — the table
111//! stops at "миллион", so a milliard falls off the end into the digit
112//! fallback above. Pre-existing, and a separate change.
113//! 4. **100000 is "жүз миң", not a lakh-style form.** The thousands branch
114//! recurses on the multiplier, so the hundreds word composes with the
115//! thousands word exactly as Kyrgyz does. Nothing to fix; noted because
116//! the sibling South Asian modules differ here.
117//!
118//! # Float/Decimal entry routing (the `"." in str(number)` rule)
119//!
120//! `to_cardinal` never asks whether the value is *whole* — it asks whether the
121//! *string* shows a point. That routing is reproduced by
122//! [`Lang::cardinal_float_entry`] here (and, through it, the ordinal/year
123//! entries), with these pinned consequences:
124//!
125//! * **Whole floats keep their ".0" tail.** `str(5.0)` == "5.0" has a point,
126//! so `to_cardinal(5.0)` == "беш үтүр нөл", *not* "беш". Likewise
127//! `Decimal("5.00")` == "беш үтүр нөл нөл" — every fractional character is
128//! spelled, trailing zeros included.
129//! * **`-0.0` renders the negword.** The sign is read off the string
130//! (`str(-0.0)` == "-0.0" starts with "-"), so `to_cardinal(-0.0)` ==
131//! "минус нөл үтүр нөл". A `< 0` test would miss it.
132//! * **Scientific notation is a `ValueError`.** `str(1e16)` == "1e+16" has no
133//! point, so Python runs `int("1e+16")` and dies: `invalid literal for
134//! int() with base 10: '1e+16'`. Same for `1e+20`, for tiny floats
135//! (`str(1e-05)` == "1e-05"), and for non-canonical Decimals
136//! (`str(Decimal("1E+2"))` == "1E+2"). The sign is stripped *before* the
137//! `int()` (Python recurses on `n[1:]`), so the message never shows a minus.
138//! * **Point-less integral Decimals take the integer grammar.**
139//! `Decimal("100")` stringifies as "100" — no point — and `int("100")`
140//! succeeds, so it renders "жүз" like the int would.
141//! * `to_ordinal(float)` is `to_cardinal(float)` + the harmonised ending
142//! ("беш үтүр нөлүнчү"); `to_ordinal_num(float)` is `str(number) + "-инчи"`
143//! ("5.0-инчи", "1e+16-инчи" — no ValueError here, nothing is parsed);
144//! `to_year(float)` is `to_cardinal(float)` (the trait default already
145//! routes through the override above).
146//! * **`"Infinity"`/`"NaN"` strings fall back to Python.** `Decimal("Infinity")`
147//! parses fine and dies later in `int("Infinity")` (ValueError) — but only
148//! on the modes that parse; `to_ordinal_num("Infinity")` happily returns
149//! "Infinity-инчи". `ParsedNumber` cannot carry Inf/NaN into that split, so
150//! `str_to_number` returns NotImplemented and the shim reruns the original
151//! pure-Python path, reproducing every mode exactly.
152//!
153//! # Sign handling
154//!
155//! Python's `to_cardinal` works on the *string* form: `n = str(number).strip()`,
156//! then `if n.startswith("-"): return (self.negword + self.to_cardinal(n[1:])).strip()`.
157//! For integral input `str(BigInt)` never contains ".", so this reduces to
158//! "recurse он the absolute value and prefix `negword`" — reproduced directly
159//! on `BigInt` here. The trailing `.strip()` is a no-op in practice (`negword`
160//! is "минус " and `_int_to_word` never returns a blank for a non-negative
161//! input) but is kept for fidelity.
162
163use crate::base::{Lang, N2WError, Result};
164use crate::currency::{CurrencyForms, CurrencyValue};
165use crate::floatpath::FloatValue;
166use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
167use bigdecimal::BigDecimal;
168use num_bigint::BigInt;
169use num_traits::{One, Signed, ToPrimitive, Zero};
170use std::collections::HashMap;
171
172/// `setup`: `self.negword`. Note the trailing space — it is the word/number
173/// separator, and the concatenation is `.strip()`ed afterwards.
174const NEGWORD: &str = "минус ";
175
176/// `setup`: `self.pointword`. Mixed Latin/Cyrillic in the original; see the
177/// module docs. Unused by the four in-scope modes.
178const POINTWORD: &str = "үтүр";
179
180/// The literal `"нөл"` that `_int_to_word` returns for 0. Python has no
181/// `self.ones[0]` entry for it — index 0 of `ones` is the empty string, and
182/// zero is special-cased ahead of the table lookup.
183const ZERO_WORD: &str = "нөл";
184
185/// The ordinal suffix used when no Kyrgyz vowel is available to harmonise
186/// against — a bare digit string, `Infinity`, `NaN`. и-class is the default
187/// because it is the class "бир" itself takes.
188const DEFAULT_ORDINAL_SUFFIX: Suffix = Suffix {
189 after_consonant: "инчи",
190 after_vowel: "нчи",
191};
192
193/// The four harmony classes of the Kyrgyz ordinal suffix.
194///
195/// Kyrgyz ordinals are the cardinal plus *-(V)нч(V)*, and both vowels agree
196/// with the last vowel of the stem. The linking vowel is dropped when the
197/// stem already ends in one, which is why "алты" gives *алтынчы* and not
198/// *алтыынчы*.
199struct Suffix {
200 /// Stem ends in a consonant — the suffix brings its own linking vowel:
201 /// кырк + ынчы, сегиз + инчи, отуз + унчу, жүз + үнчү.
202 after_consonant: &'static str,
203 /// Stem ends in a vowel — no linking vowel: алты + нчы, жети + нчи.
204 after_vowel: &'static str,
205}
206
207const SUFFIX_BACK_UNROUNDED: Suffix = Suffix {
208 after_consonant: "ынчы",
209 after_vowel: "нчы",
210};
211const SUFFIX_FRONT_UNROUNDED: Suffix = Suffix {
212 after_consonant: "инчи",
213 after_vowel: "нчи",
214};
215const SUFFIX_BACK_ROUNDED: Suffix = Suffix {
216 after_consonant: "унчу",
217 after_vowel: "нчу",
218};
219const SUFFIX_FRONT_ROUNDED: Suffix = Suffix {
220 after_consonant: "үнчү",
221 after_vowel: "нчү",
222};
223
224/// The harmony class a vowel belongs to, or `None` if the character is not a
225/// Kyrgyz vowel.
226///
227/// The four classes are the standard Kyrgyz cross-classification by backness
228/// and rounding. The Russian-only letters (ё, ю, я, and е in loanwords) are
229/// folded onto the class of the vowel they end in, which is what the borrowed
230/// numerals in this module need — "миллион" harmonises off о, "миллиард" off а.
231fn vowel_class(c: char) -> Option<&'static Suffix> {
232 match c {
233 // back, unrounded
234 'а' | 'ы' | 'я' => Some(&SUFFIX_BACK_UNROUNDED),
235 // front, unrounded
236 'е' | 'и' | 'э' => Some(&SUFFIX_FRONT_UNROUNDED),
237 // back, rounded
238 'о' | 'у' | 'ё' | 'ю' => Some(&SUFFIX_BACK_ROUNDED),
239 // front, rounded
240 'ө' | 'ү' => Some(&SUFFIX_FRONT_ROUNDED),
241 _ => None,
242 }
243}
244
245/// Pick the ordinal ending for a spelled-out cardinal.
246///
247/// Only the **last word** governs: "жүз жыйырма үч" is *жүз жыйырма үчүнчү*,
248/// harmonising off "үч" and leaving the earlier words bare. A stem with no
249/// Kyrgyz vowel at all — the digit fallback above 10^9, `Infinity`, `NaN` —
250/// falls back to [`DEFAULT_ORDINAL_SUFFIX`].
251fn ordinal_suffix(cardinal: &str) -> &'static str {
252 harmonise(cardinal).0
253}
254
255/// [`ordinal_suffix`] plus whether a Kyrgyz vowel was actually found.
256///
257/// `false` means the stem was a digit string (the 10^9 fallback), `Infinity`
258/// or `NaN` — nothing a suffix can attach to as if it were a word, so the
259/// caller writes the hyphenated digit form instead of gluing.
260fn harmonise(cardinal: &str) -> (&'static str, bool) {
261 let stem = match cardinal.split_whitespace().next_back() {
262 Some(w) => w,
263 None => return (DEFAULT_ORDINAL_SUFFIX.after_consonant, false),
264 };
265
266 let mut class: Option<&'static Suffix> = None;
267 let mut last_is_vowel = false;
268 for c in stem.chars() {
269 match vowel_class(c) {
270 Some(k) => {
271 class = Some(k);
272 last_is_vowel = true;
273 }
274 None => last_is_vowel = false,
275 }
276 }
277
278 match class {
279 Some(k) if last_is_vowel => (k.after_vowel, true),
280 Some(k) => (k.after_consonant, true),
281 None => (DEFAULT_ORDINAL_SUFFIX.after_consonant, false),
282 }
283}
284
285/// Attach the ordinal ending to a spelled cardinal.
286///
287/// A real word takes it glued — "бир" -> "биринчи". The digit fallback above
288/// 10^9 is not a word, so it takes the hyphen the digit form uses:
289/// "1000000000-инчи" rather than the unreadable "1000000000инчи".
290fn join_ordinal(cardinal: &str) -> String {
291 let (suffix, is_word) = harmonise(cardinal);
292 if is_word {
293 format!("{}{}", cardinal, suffix)
294 } else {
295 format!("{}-{}", cardinal, suffix)
296 }
297}
298
299/// `setup`: `self.ones`. Index 0 is the empty string (Python relies on this
300/// only via the float path's `self.ones[int(digit)] or "нөл"` fallback).
301const ONES: [&str; 10] = [
302 "", "бир", "эки", "үч", "төрт", "беш", "алты", "жети", "сегиз", "тогуз",
303];
304
305/// `setup`: `self.tens`. Index 0 is the empty string and is unreachable —
306/// the `number < 100` branch only runs for `number >= 10`, so `t >= 1`.
307const TENS: [&str; 10] = [
308 "", "он", "жыйырма", "отуз", "кырк", "элүү", "алтымыш", "жетимиш", "сексен", "токсон",
309];
310
311/// `setup`: `self.hundred`.
312const HUNDRED: &str = "жүз";
313
314/// `setup`: `self.thousand`. Spelled "миң" in the source — see module docs.
315const THOUSAND: &str = "миң";
316
317/// `setup`: `self.million`.
318const MILLION: &str = "миллион";
319
320/// 10^9 — the ceiling past which `_int_to_word` gives up and returns digits.
321fn one_e9() -> BigInt {
322 BigInt::from(1_000_000_000u64)
323}
324
325/// The key `to_currency` falls back to for an unknown currency code.
326///
327/// Python writes `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
328/// — the *first* entry in the class dict, which since Python 3.7 means the
329/// first one in the literal. That is "KGS". A `HashMap` has no insertion
330/// order, so the identity of that first entry is pinned here rather than
331/// recovered from iteration. Verified live:
332/// `list(Num2Word_KY.CURRENCY_FORMS.values())[0] == (("сом","сом"),("тыйын","тыйын"))`.
333const FALLBACK_CURRENCY: &str = "KGS";
334
335/// `Num2Word_KY.CURRENCY_FORMS`, verbatim.
336///
337/// KY declares its **own** class-level dict, so — unlike the 16 classes that
338/// read `Num2Word_EUR`'s dict after `Num2Word_EN.__init__` has mutated it —
339/// none of EN's ~24 extra ISO codes (GBP, JPY, KWD, BHD, INR, CNY, CHF, ...)
340/// exist here. The corpus proves the isolation: `to_cheque(1234.56, "GBP")`
341/// raises NotImplementedError rather than rendering pounds.
342///
343/// Every entry carries exactly two forms, and both are identical — Kyrgyz does
344/// not inflect the counted noun. `to_currency` still indexes `[0]`/`[1]`
345/// literally, so the arity is kept even though the two are indistinguishable.
346fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
347 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
348 // Listed in the order of the Python dict literal. Only the first entry's
349 // identity matters (see FALLBACK_CURRENCY); the rest is a plain lookup.
350 m.insert("KGS", CurrencyForms::new(&["сом", "сом"], &["тыйын", "тыйын"]));
351 m.insert("USD", CurrencyForms::new(&["доллар", "доллар"], &["сент", "сент"]));
352 m.insert("EUR", CurrencyForms::new(&["евро", "евро"], &["сент", "сент"]));
353 m.insert("RUB", CurrencyForms::new(&["рубль", "рубль"], &["копейка", "копейка"]));
354 m
355}
356
357/// `parts = str(abs(val)).split(".")` → `(left, right)`, for the float/Decimal
358/// arm of `to_currency`.
359///
360/// Python reads the *decimal string*, not the number:
361///
362/// ```text
363/// parts = str(val).split(".")
364/// left = int(parts[0]) if parts[0] else 0
365/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
366/// ```
367///
368/// `BigDecimal`'s `(int_val, scale)` pair *is* that string's structure —
369/// "12.34" parses to `(1234, 2)`, "0.5" to `(5, 1)`, "1.0" to `(10, 1)` — so
370/// the split is done on the digits rather than by re-formatting, which would
371/// depend on `Display` agreeing with Python's `str()`.
372///
373/// Two behaviours here are Python's, not conveniences:
374///
375/// * **`[:2]` truncates, never rounds.** `12.345` → 34 cents, not 35.
376/// * **`.ljust(2, "0")` pads on the *right*.** `0.5` → parts[1] == "5" → "50"
377/// → **50** cents, not 5. Dropping the pad turns "нөл евро элүү сент" into
378/// "нөл евро беш сент".
379///
380/// `abs()` runs before `str()` in Python, and negating changes no digit and no
381/// scale, so taking `abs` of `int_val` here is equivalent.
382fn split_currency_parts(value: &BigDecimal) -> Result<(BigInt, BigInt)> {
383 let (int_val, scale) = value.as_bigint_and_exponent();
384 let int_val = int_val.abs();
385
386 // scale < 0 means the literal carried a positive exponent, which is the
387 // only way `BigDecimal::from_str` produces one. Python's `str()` keeps
388 // that form for floats (`str(1e21) == "1e+21"`) and for Decimals
389 // (`str(Decimal("1E+2")) == "1E+2"`) alike, so `.split(".")` yields a
390 // one-element list and `int("1e+21")` raises ValueError. Confirmed live:
391 // >>> Num2Word_KY().to_currency(1e21, "EUR")
392 // ValueError: invalid literal for int() with base 10: '1e+21'
393 // Both possible source types agree, so this branch is unambiguous.
394 if scale < 0 {
395 return Err(N2WError::Value(format!(
396 "invalid literal for int() with base 10: '{}'",
397 value
398 )));
399 }
400
401 // No ".": `len(parts) > 1` is false, so `right` stays 0.
402 if scale == 0 {
403 return Ok((int_val, BigInt::zero()));
404 }
405
406 // The last `scale` digits are what `str()` prints after the "."; pad on
407 // the left so there is always at least one integer digit ("0.01" → "001",
408 // giving parts[0] == "0" and parts[1] == "01").
409 let digits = int_val.to_string();
410 let s = scale as usize;
411 let padded = if digits.len() <= s {
412 format!("{}{}", "0".repeat(s - digits.len() + 1), digits)
413 } else {
414 digits
415 };
416 let split = padded.len() - s;
417 let left: BigInt = padded[..split].parse().expect("digits only");
418
419 // parts[1][:2].ljust(2, "0")
420 let mut two: String = padded[split..].chars().take(2).collect();
421 while two.len() < 2 {
422 two.push('0');
423 }
424 let right: BigInt = two.parse().expect("digits only");
425 Ok((left, right))
426}
427
428/// Reconstruct Python's `str(number).split(".")` for the float/Decimal cardinal
429/// path, returning `(abs_left, right_digits, has_dot)`.
430///
431/// KY's `to_cardinal` reads the *decimal string*, not a rounded `(pre, post)`
432/// tuple: every character after the "." is spelled as its own digit, with no
433/// rounding, truncation, `< 0.01` heuristic, or `precision` consultation. So
434/// this reproduces that string rather than routing through
435/// `Num2Word_Base.float2tuple` (which KY's override never reaches).
436///
437/// * **Decimal** — the `(int_val, scale)` pair *is* the string's structure, so
438/// the split is exact: "1.10" → left 1, right "10" (the trailing zero
439/// survives because `BigDecimal` keeps the scale); "0.001" → left 0, right
440/// "001"; "98746251323029.99" → left 98746251323029, right "99".
441/// * **Float** — formatted to exactly `precision` fractional places. `precision`
442/// is Python's shortest-round-trip digit count (`abs(Decimal(str(value))
443/// .as_tuple().exponent)`, computed on the Python side), so `{:.precision}`
444/// reproduces `str(float)` across the whole reachable range. The f64 artefacts
445/// that force `base.float2tuple`'s heuristic never surface here: `2.675`
446/// formats straight to "2.675" and `1.005` to "1.005", because `str()`
447/// already collapses the binary noise to the clean shortest form.
448///
449/// `abs()` is taken first (on the f64, or on `int_val`), mirroring Python
450/// stripping the leading "-" off the string before re-parsing; negating changes
451/// no digit and no scale, so the absolute reconstruction is identical.
452fn decompose_float(v: &FloatValue) -> (BigInt, String, bool) {
453 match v {
454 FloatValue::Decimal { value, .. } => {
455 // Use the parsed scale, not the passed `precision`: they agree for
456 // every reachable Decimal (both are `abs(exponent)`), and the scale
457 // is what actually carries the digit count of `str()`.
458 let (int_val, scale) = value.as_bigint_and_exponent();
459 let abs = int_val.abs();
460 if scale <= 0 {
461 // Integral Decimal: `str()` has no ".", so Python's integer
462 // branch runs. Unreachable from the dispatcher (integral values
463 // take the int path), handled defensively as the integer form.
464 return (abs, String::new(), false);
465 }
466 let digits = abs.to_string();
467 let s = scale as usize;
468 // Pad on the left so there is always at least one integer digit
469 // ("0.01" → "001" → left "0", right "01").
470 let padded = if digits.len() <= s {
471 format!("{}{}", "0".repeat(s - digits.len() + 1), digits)
472 } else {
473 digits
474 };
475 let split = padded.len() - s;
476 let left: BigInt = padded[..split].parse().expect("digits only");
477 // Exactly `scale` characters — the full fractional part, no `[:2]`
478 // truncation (that is the currency path's quirk, not this one).
479 let right = padded[split..].to_string();
480 (left, right, true)
481 }
482 FloatValue::Float { value, precision } => {
483 // Fixed-notation, so large magnitudes never flip to Rust's `1e21`
484 // Display form; `precision` fractional places reproduce `str()`.
485 let s = format!("{:.*}", *precision as usize, value.abs());
486 match s.split_once('.') {
487 Some((l, r)) => (l.parse().expect("digits only"), r.to_string(), true),
488 None => (s.parse().expect("digits only"), String::new(), false),
489 }
490 }
491 }
492}
493
494/// Python's `int(s)` failure, message verbatim from CPython. The `s` is the
495/// point-less `str(number)` slice KY feeds it — sign already stripped, because
496/// `to_cardinal` peels the "-" off the string and recurses *before* `int()`.
497fn int_value_error(s: &str) -> N2WError {
498 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
499}
500
501/// Python `repr(float)` for the scientific range, on an already-`abs()`ed
502/// finite f64 (`a >= 1e16`, or `0 < a < 1e-4`).
503///
504/// Rust's `{:e}` yields the shortest round-trip mantissa — the same digits
505/// CPython's repr picks — but lays the exponent out as `1e16` / `1e-5` where
506/// Python writes `1e+16` / `1e-05`: always a sign, zero-padded to two digits.
507/// Only the exponent dressing needs fixing up.
508fn py_float_sci_repr(a: f64) -> String {
509 let sci = format!("{:e}", a); // "1e16", "1.5e20", "1e-5"
510 let (mant, exp_str) = sci.split_once('e').expect("{:e} always emits an 'e'");
511 let exp: i64 = exp_str.parse().expect("{:e} exponent is a base-10 integer");
512 format!(
513 "{}e{}{:02}",
514 mant,
515 if exp < 0 { '-' } else { '+' },
516 exp.abs()
517 )
518}
519
520/// Where `str(number)` sends the value in `to_cardinal` (sign already peeled).
521enum StrRoute {
522 /// `"." in n` — the decimal grammar spells every fractional character.
523 Point,
524 /// No "." and `int(n)` succeeds — `_int_to_word` runs on this magnitude.
525 WholeDigits(BigInt),
526}
527
528/// Reproduce Python's `"." in str(number)` routing, including the `int(n)`
529/// `ValueError` for the point-less forms that are not integer literals.
530///
531/// * **Float** — repr is fixed-notation (always with a ".") for every finite
532/// value outside the scientific range, so the only no-point floats are the
533/// scientific ones (`|v| >= 1e16`, `0 < |v| < 1e-4`) and inf/nan — all of
534/// which make `int()` raise. `FloatValue::has_visible_point` is *not* used
535/// here: it reports `true` for tiny scientific floats (`1e-05`), whose repr
536/// has no point and must raise.
537/// * **Decimal** — `str(Decimal)` is reconstructed by `python_decimal_str`;
538/// a point routes to the decimal grammar, plain digits (canonical integral
539/// Decimals like "100") parse, and everything else ("1E+2", "1E+20") is the
540/// `int()` ValueError.
541fn route_by_str(v: &FloatValue) -> Result<StrRoute> {
542 match v {
543 FloatValue::Float { value, .. } => {
544 let a = value.abs();
545 if !a.is_finite() {
546 // str(float("inf")) == "inf", str(float("nan")) == "nan" —
547 // no ".", so int() raises. The "-" of "-inf" is stripped by
548 // the string-sign recursion before int() ever sees it.
549 return Err(int_value_error(if a.is_nan() { "nan" } else { "inf" }));
550 }
551 if a >= 1e16 || (a > 0.0 && a < 1e-4) {
552 // repr picked exponent form: no "." in n -> int(n) raises.
553 return Err(int_value_error(&py_float_sci_repr(a)));
554 }
555 // Every other finite float reprs with a point ("5.0", "0.0001").
556 Ok(StrRoute::Point)
557 }
558 FloatValue::Decimal { value, .. } => {
559 // abs() first: Python strips the string sign before this test.
560 let s = python_decimal_str(&value.abs());
561 if s.contains('.') {
562 Ok(StrRoute::Point)
563 } else if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
564 Ok(StrRoute::WholeDigits(s.parse().expect("digits only")))
565 } else {
566 // "1E+2", "1E+20", ... — int() raises.
567 Err(int_value_error(&s))
568 }
569 }
570 }
571}
572
573pub struct LangKy {
574 exclude_title: Vec<String>,
575 forms: HashMap<&'static str, CurrencyForms>,
576}
577
578impl Default for LangKy {
579 fn default() -> Self {
580 Self::new()
581 }
582}
583
584impl LangKy {
585 pub fn new() -> Self {
586 LangKy {
587 // `setup`: self.exclude_title = ["минус", "үтүр"] — note "минус"
588 // here has no trailing space, unlike `negword`.
589 exclude_title: vec![NEGWORD.trim().to_string(), POINTWORD.to_string()],
590 // Built once here, never per call. `to_currency` only ever reads
591 // this table, and rebuilding it per call is what made an earlier
592 // revision of this port slower than the Python it replaces.
593 forms: build_currency_forms(),
594 }
595 }
596
597 /// Port of `_int_to_word`, entry point on `BigInt`.
598 ///
599 /// Splits into the arbitrary-precision concerns (zero, the >= 10^9 digit
600 /// fallback, and the unreachable negative-index quirk) and then hands the
601 /// proven-bounded remainder to [`LangKy::int_to_word_small`].
602 fn int_to_word(&self, number: &BigInt) -> Result<String> {
603 // `if number == 0: return "нөл"`
604 if number.is_zero() {
605 return Ok(ZERO_WORD.to_string());
606 }
607
608 if number.is_negative() {
609 // Unreachable from the four in-scope modes: `to_cardinal` strips
610 // the "-" off the *string* before it ever calls `_int_to_word`.
611 // Modelled anyway so this cannot panic, and because Python's
612 // behaviour here is a genuine quirk rather than an error: a
613 // negative `number` satisfies `number < 10`, so Python evaluates
614 // `self.ones[number]` — a *negative list index*. For -10..=-1 that
615 // silently wraps to `ONES[number + 10]` (so `_int_to_word(-5)`
616 // == "беш"); anything <= -11 is out of range and raises
617 // IndexError.
618 let wrapped = number + BigInt::from(10);
619 return match wrapped.to_usize() {
620 Some(i) if i < ONES.len() => Ok(ONES[i].to_string()),
621 _ => Err(N2WError::Index("list index out of range".to_string())),
622 };
623 }
624
625 // Quirk 1: the final `return str(number)`. No OverflowError, no
626 // milliard word — just the decimal digits. Must be checked on the
627 // BigInt, since `number` is unbounded above.
628 if number >= &one_e9() {
629 return Ok(number.to_string());
630 }
631
632 // Proven: 0 < number < 10^9, so a u64 is lossless from here down.
633 let n = number
634 .to_u64()
635 .expect("0 < number < 10^9 was just proven, so u64 conversion cannot fail");
636 Ok(self.int_to_word_small(n))
637 }
638
639 /// Port of `_int_to_word`'s spelled-out branches, for `0 <= n < 10^9`.
640 ///
641 /// Each `+ (" " + ... if x else "")` in Python is a conditional suffix,
642 /// *not* an unconditional join — hence the explicit `!= 0` guards, which
643 /// are what keep 100 at "жүз" rather than "жүз нөл".
644 fn int_to_word_small(&self, n: u64) -> String {
645 // Mirrors Python's first line. Every call site below guards `r != 0`,
646 // so this is defensive rather than reachable.
647 if n == 0 {
648 return ZERO_WORD.to_string();
649 }
650
651 // `if number < 10: return self.ones[number]`
652 if n < 10 {
653 return ONES[n as usize].to_string();
654 }
655
656 // `if number < 100:`
657 // `t, o = divmod(number, 10)`
658 // `return self.tens[t] + (" " + self.ones[o] if o else "")`
659 if n < 100 {
660 let (t, o) = (n / 10, n % 10);
661 let mut s = TENS[t as usize].to_string();
662 if o != 0 {
663 s.push(' ');
664 s.push_str(ONES[o as usize]);
665 }
666 return s;
667 }
668
669 // `if number < 1000:`
670 // `h, r = divmod(number, 100)`
671 // `base = (self.ones[h] + " " if h > 1 else "") + self.hundred`
672 // `return base + (" " + self._int_to_word(r) if r else "")`
673 // Quirk 2: `h > 1` suppresses "бир", so 100 == "жүз".
674 if n < 1_000 {
675 let (h, r) = (n / 100, n % 100);
676 let mut s = String::new();
677 if h > 1 {
678 s.push_str(ONES[h as usize]);
679 s.push(' ');
680 }
681 s.push_str(HUNDRED);
682 if r != 0 {
683 s.push(' ');
684 s.push_str(&self.int_to_word_small(r));
685 }
686 return s;
687 }
688
689 // `if number < 1000000:`
690 // `t, r = divmod(number, 1000)`
691 // `base = (self._int_to_word(t) + " " if t > 1 else "") + self.thousand`
692 // `return base + (" " + self._int_to_word(r) if r else "")`
693 // Quirk 2 again: `t > 1` suppresses "бир", so 1000 == "миң".
694 if n < 1_000_000 {
695 let (t, r) = (n / 1_000, n % 1_000);
696 let mut s = String::new();
697 if t > 1 {
698 s.push_str(&self.int_to_word_small(t));
699 s.push(' ');
700 }
701 s.push_str(THOUSAND);
702 if r != 0 {
703 s.push(' ');
704 s.push_str(&self.int_to_word_small(r));
705 }
706 return s;
707 }
708
709 // `if number < 1000000000:`
710 // `m, r = divmod(number, 1000000)`
711 // `base = self._int_to_word(m) + " " + self.million`
712 // `return base + (" " + self._int_to_word(r) if r else "")`
713 // Quirk 2, inverted: no `m > 1` guard here, so 10^6 == "бир миллион".
714 let (m, r) = (n / 1_000_000, n % 1_000_000);
715 let mut s = self.int_to_word_small(m);
716 s.push(' ');
717 s.push_str(MILLION);
718 if r != 0 {
719 s.push(' ');
720 s.push_str(&self.int_to_word_small(r));
721 }
722 s
723 }
724}
725
726impl Lang for LangKy {
727 /// This language's own `to_currency(currency=...)` default,
728 /// read from the live Python signature. Only 44 of 156 use EUR.
729 fn default_currency(&self) -> &str {
730 "KGS"
731 }
732
733 /// This language's own `to_currency(separator=...)` default,
734 /// read from the live Python signature. Base's is ",", but only
735 /// 36 of 149 languages actually use it — most default to " " or a
736 /// conjunction, so inheriting Base's comma silently corrupts them.
737 fn default_separator(&self) -> &str {
738 " "
739 }
740
741 fn negword(&self) -> &str {
742 NEGWORD
743 }
744
745 fn pointword(&self) -> &str {
746 "үтүр"
747 }
748
749 // `is_title` is left at Num2Word_Base's `False`; `setup` never touches it.
750 // KY's `to_cardinal` override never calls `title()` anyway.
751
752 fn exclude_title(&self) -> &[String] {
753 &self.exclude_title
754 }
755
756 /// Port of `to_cardinal`.
757 ///
758 /// The Python operates on `str(number)`; for integral input the "." branch
759 /// is dead and the "-" branch reduces to a recursion on the absolute
760 /// value. See the module docs.
761 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
762 // `if n.startswith("-"):`
763 // `return (self.negword + self.to_cardinal(n[1:])).strip()`
764 if value.is_negative() {
765 let inner = self.to_cardinal(&-value)?;
766 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
767 }
768 // `return self._int_to_word(int(n))`
769 self.int_to_word(value)
770 }
771
772 /// The cardinal with the harmonised ordinal ending glued to its **last
773 /// word** — "бир" -> "биринчи", "үч" -> "үчүнчү", "жүз жыйырма үч" ->
774 /// "жүз жыйырма үчүнчү". See [`ordinal_suffix`].
775 ///
776 /// No `verify_ordinal`, matching upstream: negatives and zero still pass
777 /// straight through rather than raising, so `to_ordinal(-1)` is
778 /// "минус биринчи" and `to_ordinal(0)` is "нөлүнчү". Past 10^9 it
779 /// inherits the digit fallback and the suffix has no vowel to harmonise
780 /// with, so it takes the hyphenated digit form:
781 /// `to_ordinal(10**9)` == "1000000000-инчи".
782 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
783 let cardinal = self.to_cardinal(value)?;
784 Ok(join_ordinal(&cardinal))
785 }
786
787 /// The digits, a hyphen, and the same harmonised ending the spelled form
788 /// would take: "1-инчи", "3-үнчү", "10-унчу", "40-ынчы".
789 ///
790 /// The ending is read off the *spelled* cardinal, because that is the
791 /// word a reader supplies when reading the digits aloud — there is no
792 /// vowel in "3" to harmonise against. Above 10^9 `to_cardinal` returns
793 /// the digits themselves, which have no vowel either, so the default
794 /// class applies.
795 ///
796 /// The minus sign survives, as upstream: `to_ordinal_num(-1)` is
797 /// "-1-инчи".
798 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
799 let suffix = match self.to_cardinal(value) {
800 Ok(cardinal) => ordinal_suffix(&cardinal),
801 // Unreachable for an integer — `to_cardinal` has no failing
802 // branch on this path — but the digit form must not start
803 // raising where it used to succeed.
804 Err(_) => DEFAULT_ORDINAL_SUFFIX.after_consonant,
805 };
806 Ok(format!("{}-{}", value, suffix))
807 }
808
809 /// Port of `to_year`: `return self.to_cardinal(val)`.
810 ///
811 /// KY declares `longval=True` but ignores it, and does no century
812 /// pairing or BC/AD handling — years are plain cardinals, so
813 /// `to_year(1984)` == "миң тогуз жүз сексен төрт" and `to_year(-44)` ==
814 /// "минус кырк төрт".
815 fn to_year(&self, value: &BigInt) -> Result<String> {
816 self.to_cardinal(value)
817 }
818
819 /// Port of `to_cardinal`'s float/Decimal branch.
820 ///
821 /// `Num2Word_KY` overrides `to_cardinal` and handles non-integers *inline*
822 /// off `str(number)`; it never reaches `Num2Word_Base.to_cardinal_float`
823 /// / `float2tuple`. So this reproduces that string-based branch directly:
824 ///
825 /// ```text
826 /// n = str(number).strip()
827 /// if n.startswith("-"):
828 /// return (self.negword + self.to_cardinal(n[1:])).strip()
829 /// if "." in n:
830 /// left, right = n.split(".", 1)
831 /// ret = self._int_to_word(int(left)) + " " + self.pointword
832 /// for digit in right:
833 /// ret += " " + (self.ones[int(digit)] or "нөл")
834 /// return ret.strip()
835 /// ```
836 ///
837 /// Notes on fidelity:
838 ///
839 /// * **No rounding, truncation, or precision.** Each fractional character is
840 /// spelled as its own digit; `2.675` → "эки үтүр алты жети беш" and
841 /// `Decimal("1.10")` → "бир үтүр бир нөл" (the trailing zero is kept).
842 /// * **`precision_override` is ignored.** KY's `to_cardinal` never reads
843 /// `self.precision`, so the `precision=` kwarg has no effect on the
844 /// output — verified live: `num2words(2.675, lang="ky", precision=1)`
845 /// still yields all three fractional digits.
846 /// * **The `>= 10^9` digit fallback reaches the integer part.** The `left`
847 /// of `98746251323029.99` renders as its bare digits, since `_int_to_word`
848 /// gives up past a billion.
849 /// * **The fractional digit uses `self.ones[d]`, not `_int_to_word`.**
850 /// `ones[0]` is `""` (falsy) → "нөл"; every other index is its own word.
851 fn to_cardinal_float(
852 &self,
853 value: &FloatValue,
854 _precision_override: Option<u32>,
855 ) -> Result<String> {
856 // `if n.startswith("-"):` — the sign is read off the string, then the
857 // absolute value is rendered and prefixed with negword.
858 let is_negative = value.is_negative();
859 let (left, right, has_dot) = decompose_float(value);
860
861 let mut body = if has_dot {
862 // `ret = self._int_to_word(int(left)) + " " + self.pointword`
863 let mut s = format!("{} {}", self.int_to_word(&left)?, POINTWORD);
864 // `for digit in right: ret += " " + (self.ones[int(digit)] or "нөл")`
865 for ch in right.chars() {
866 let d = ch.to_digit(10).expect("fractional part is digits only") as usize;
867 s.push(' ');
868 // `self.ones[d] or "нөл"` — ONES[0] == "" is falsy → "нөл".
869 s.push_str(if ONES[d].is_empty() { ZERO_WORD } else { ONES[d] });
870 }
871 s
872 } else {
873 // No ".": Python falls through to `_int_to_word(int(n))`. Only
874 // reachable for the defensive integral-Decimal case above.
875 self.int_to_word(&left)?
876 };
877
878 // `(self.negword + ...).strip()` — negword ("минус ") carries its own
879 // trailing space, which becomes the separator.
880 if is_negative {
881 body = format!("{}{}", NEGWORD, body);
882 }
883 Ok(body.trim().to_string())
884 }
885
886 /// `to_cardinal(float/Decimal)` — the full entry, routing on
887 /// `"." in str(number)` rather than on whole-ness (see the module docs).
888 ///
889 /// A whole float therefore keeps its ".0" tail (`5.0` -> "беш үтүр нөл",
890 /// `-0.0` -> "минус нөл үтүр нөл"), a point-less integral Decimal takes
891 /// the integer grammar (`Decimal("100")` -> "жүз"), and a point-less
892 /// non-integer form is Python's `int()` ValueError (`1e+16`,
893 /// `Decimal("1E+2")`).
894 ///
895 /// `precision_override` is threaded through untouched; KY's grammar never
896 /// reads a precision, so it is inert either way (see `to_cardinal_float`).
897 fn cardinal_float_entry(
898 &self,
899 value: &FloatValue,
900 precision_override: Option<u32>,
901 ) -> Result<String> {
902 match route_by_str(value)? {
903 StrRoute::Point => self.to_cardinal_float(value, precision_override),
904 StrRoute::WholeDigits(abs_int) => {
905 // `return self._int_to_word(int(n))`, with the string sign
906 // already peeled and re-attached as negword by the recursion:
907 // `(self.negword + self.to_cardinal(n[1:])).strip()`.
908 let body = self.int_to_word(&abs_int)?;
909 if value.is_negative() {
910 Ok(format!("{}{}", NEGWORD, body).trim().to_string())
911 } else {
912 Ok(body)
913 }
914 }
915 }
916 }
917
918 /// `to_ordinal(float/Decimal)`: the float cardinal plus the harmonised
919 /// ending, so `5.0` == "беш үтүр нөлүнчү" (the last spoken word is "нөл",
920 /// which harmonises to ү-class). The ValueError of `1e+16` propagates
921 /// unchanged, as upstream.
922 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
923 let cardinal = self.cardinal_float_entry(value, None)?;
924 Ok(join_ordinal(&cardinal))
925 }
926
927 /// `to_ordinal_num(float/Decimal)`: `str(number)`, a hyphen and the
928 /// default ending. Purely textual — nothing is parsed, so the scientific
929 /// forms still succeed: `1e+16` == "1e+16-инчи",
930 /// `Decimal("1E+2")` == "1E+2-инчи". A decimal repr has no Kyrgyz vowel
931 /// to harmonise against, so the class cannot be recovered here.
932 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
933 Ok(format!(
934 "{}-{}",
935 repr_str, DEFAULT_ORDINAL_SUFFIX.after_consonant
936 ))
937 }
938
939 // `year_float_entry` is deliberately NOT overridden: KY's `to_year` is
940 // `self.to_cardinal(val)`, and the trait default routes through the
941 // overridden `cardinal_float_entry` above — so `to_year(5.0)` == "беш
942 // үтүр нөл" and `to_year(1e+16)` raises ValueError, as the corpus pins.
943
944 /// `converter.str_to_number` — Base's `Decimal(value)`, which KY does not
945 /// override. `ParsedNumber` cannot carry Inf/NaN into KY's per-mode split
946 /// (`to_cardinal` dies in `int("Infinity")` with ValueError, but
947 /// `to_ordinal_num` returns "Infinity-инчи" successfully), so both
948 /// return NotImplemented: the shim catches it and reruns the original
949 /// pure-Python string path, which produces exactly those outcomes for
950 /// every mode.
951 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
952 // Inf/NaN are carried through natively now (see `inf_result` /
953 // `nan_result`), so the per-mode split runs in Rust instead of the
954 // Python fallback.
955 python_decimal_parse(s)
956 }
957
958 /// `Decimal('Infinity')` / `Decimal('-Infinity')` per mode. KY overrides
959 /// every in-scope method and reads `str(number)`:
960 ///
961 /// * `to_cardinal` / `to_ordinal` (== cardinal + suffix) / `to_year`
962 /// (== cardinal) do `int("Infinity")` after stripping the sign, so they
963 /// raise `ValueError` with the sign-stripped token — never OverflowError.
964 /// * `to_ordinal_num` is purely textual; nothing is parsed, so it
965 /// succeeds ("Infinity-инчи" / "-Infinity-инчи").
966 ///
967 /// The currency/fraction fallbacks also `int()` the token, so they land on
968 /// the same ValueError.
969 fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
970 match to {
971 "ordinal_num" => Ok(format!(
972 "{}-{}",
973 if negative { "-Infinity" } else { "Infinity" },
974 DEFAULT_ORDINAL_SUFFIX.after_consonant
975 )),
976 // Sign is peeled off the string before `int()`, so the message
977 // always quotes the bare "Infinity".
978 _ => Err(int_value_error("Infinity")),
979 }
980 }
981
982 /// `Decimal('NaN')` per mode — same split as `inf_result`: the parsing
983 /// modes `int("NaN")` → ValueError, while `to_ordinal_num` returns
984 /// "NaN-инчи" without parsing.
985 fn nan_result(&self, to: &str) -> Result<String> {
986 match to {
987 "ordinal_num" => Ok(format!(
988 "NaN-{}",
989 DEFAULT_ORDINAL_SUFFIX.after_consonant
990 )),
991 _ => Err(int_value_error("NaN")),
992 }
993 }
994
995 // ---- currency -------------------------------------------------------
996 //
997 // `Num2Word_KY` overrides `to_currency` and `pluralize`, and adds its own
998 // `CURRENCY_FORMS`. Everything else on the currency path — `to_cheque`,
999 // `_money_verbose`, `_cents_verbose`, `_cents_terse` — is inherited from
1000 // `Num2Word_Base` untouched, so the trait defaults already mirror it.
1001 //
1002 // It defines neither `CURRENCY_PRECISION` nor `CURRENCY_ADJECTIVES`, so
1003 // both stay at `Num2Word_Base`'s empty dicts: `currency_precision` is 100
1004 // for every code (the trait default) and `currency_adjective` is always
1005 // None. Not overriding them here *is* the port.
1006
1007 fn lang_name(&self) -> &str {
1008 "Num2Word_KY"
1009 }
1010
1011 /// `CURRENCY_FORMS[code]` — a **strict** lookup, missing code → None.
1012 ///
1013 /// This is the subscript in `Num2Word_Base.to_cheque`
1014 /// (`cr1, _cr2 = self.CURRENCY_FORMS[currency]`, whose `KeyError` is
1015 /// caught and re-raised as NotImplementedError). `to_currency` must *not*
1016 /// route through here — it uses `.get(currency, <first entry>)` and so
1017 /// never raises. That asymmetry is real and the corpus pins both halves:
1018 ///
1019 /// ```text
1020 /// currency:GBP 12.34 → "он эки сом отуз төрт тыйын" (falls back to KGS)
1021 /// cheque:GBP 1234.56 → NotImplementedError
1022 /// ```
1023 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1024 self.forms.get(code)
1025 }
1026
1027 /// Port of `Num2Word_KY.pluralize`:
1028 ///
1029 /// ```text
1030 /// if not forms:
1031 /// return ""
1032 /// return forms[0] if n == 1 else forms[-1]
1033 /// ```
1034 ///
1035 /// Dead code in practice: KY's `to_currency` indexes `cr1`/`cr2` directly
1036 /// instead of calling this, and `Num2Word_Base.to_cheque` never pluralizes
1037 /// (it takes `cr1[-1]` unconditionally). Ported because it is a real
1038 /// override, and it would come alive if `to_currency` were ever reverted
1039 /// to Base's. Note the empty-`forms` guard — unlike `Num2Word_EUR`'s
1040 /// version, this one cannot raise IndexError.
1041 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1042 if forms.is_empty() {
1043 return Ok(String::new());
1044 }
1045 Ok(if n.is_one() {
1046 forms[0].clone()
1047 } else {
1048 forms[forms.len() - 1].clone()
1049 })
1050 }
1051
1052 /// Port of `Num2Word_KY.to_currency`.
1053 ///
1054 /// ```text
1055 /// is_negative = val < 0
1056 /// val = abs(val)
1057 /// parts = str(val).split(".")
1058 /// left = int(parts[0]) if parts[0] else 0
1059 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
1060 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
1061 /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
1062 /// if cents and right:
1063 /// result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
1064 /// if is_negative:
1065 /// result = self.negword + result
1066 /// return result.strip()
1067 /// ```
1068 ///
1069 /// A wholesale replacement of `Num2Word_Base.to_currency` that shares
1070 /// almost nothing with it. Four differences carry the behaviour:
1071 ///
1072 /// 1. **An unknown currency never raises.** Base subscripts the dict and
1073 /// turns the `KeyError` into NotImplementedError; KY `.get`s it with the
1074 /// first entry (KGS) as the default, so `currency:JPY` renders soms.
1075 /// Every non-KGS/USD/EUR/RUB row in the corpus is that fallback.
1076 /// 2. **`CURRENCY_PRECISION` is never consulted.** The divisor is hard-wired
1077 /// to 100 by `parts[1][:2]`, so the 3-decimal (KWD/BHD) and 0-decimal
1078 /// (JPY) branches of Base simply do not exist — `currency:JPY 12.34` is
1079 /// "он эки сом отуз төрт тыйын", subunits and all, where Base would round
1080 /// to a whole yen and drop them.
1081 /// 3. **`has_decimal` is irrelevant.** Base prints a zero-cents segment for
1082 /// any float; KY gates on `right` being non-zero, so `1.0` → "бир евро"
1083 /// and `Decimal("5.00")` → "беш евро" (both verified live).
1084 /// 4. **`adjective` is accepted and ignored** — there is no
1085 /// `CURRENCY_ADJECTIVES` to consult and no call to `prefix_currency`.
1086 ///
1087 /// `pluralize` is bypassed too: the forms are indexed literally.
1088 fn to_currency(
1089 &self,
1090 val: &CurrencyValue,
1091 currency: &str,
1092 cents: bool,
1093 separator: Option<&str>,
1094 _adjective: bool,
1095 ) -> Result<String> {
1096 // None = the caller omitted separator=, so KY's own default (" ")
1097 // applies. Resolved here because overriding `to_currency` bypasses the
1098 // trait default's body, which is where that normally happens.
1099 let separator = separator.unwrap_or(self.default_separator());
1100
1101 // `is_negative = val < 0` — evaluated before `abs`, on the number.
1102 let is_negative = val.is_negative();
1103
1104 let (left, right) = match val {
1105 // `str(int)` never contains a ".", so `parts` is one element and
1106 // `right` stays 0. This is why `currency:EUR 1` shows no cents
1107 // while `currency:EUR 1.0` goes through the branch below (and then
1108 // shows none anyway, because right == 0).
1109 CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
1110 CurrencyValue::Decimal { value, .. } => split_currency_parts(value)?,
1111 };
1112
1113 // `.get(currency, list(values())[0])` — the default is always KGS.
1114 let forms = self
1115 .forms
1116 .get(currency)
1117 .or_else(|| self.forms.get(FALLBACK_CURRENCY))
1118 .expect("KGS is always present");
1119 let (cr1, cr2) = (&forms.unit, &forms.subunit);
1120
1121 // `cr1[1] if left != 1 else cr1[0]` — a literal tuple index, not
1122 // `pluralize`. Both KY forms are identical, so the branch is invisible
1123 // in the output; it is kept because it is what Python evaluates.
1124 let unit = index_form(cr1, &left)?;
1125 let mut result = format!("{} {}", self.int_to_word(&left)?, unit);
1126
1127 // `if cents and right:` — `right` is falsy at 0, which is what
1128 // suppresses the segment for "1.0" and "100.0".
1129 if cents && !right.is_zero() {
1130 let sub = index_form(cr2, &right)?;
1131 // Python concatenates `separator` with no space of its own: the
1132 // default separator " " *is* the gap. So separator="," yields
1133 // "он эки евро,отуз төрт сент" — verified live. Do not add a
1134 // space here to make it look like Base's "%s%s %s%s %s %s".
1135 result.push_str(separator);
1136 result.push_str(&self.int_to_word(&right)?);
1137 result.push(' ');
1138 result.push_str(sub);
1139 }
1140
1141 if is_negative {
1142 result = format!("{}{}", NEGWORD, result);
1143 }
1144 // `.strip()` — a no-op for every reachable input (`negword` is the only
1145 // thing that could add whitespace and it prepends), kept for fidelity.
1146 Ok(result.trim().to_string())
1147 }
1148}
1149
1150/// `forms[1] if n != 1 else forms[0]`.
1151///
1152/// Python indexes the tuple directly, so a single-form entry with `n != 1`
1153/// would raise IndexError. Every `Num2Word_KY` entry has exactly two forms, so
1154/// that is unreachable — but it is mapped to `Index` rather than allowed to
1155/// panic, so the exception *type* survives if the table ever changes.
1156fn index_form<'a>(forms: &'a [String], n: &BigInt) -> Result<&'a str> {
1157 let i = if n.is_one() { 0 } else { 1 };
1158 forms
1159 .get(i)
1160 .map(|s| s.as_str())
1161 .ok_or_else(|| N2WError::Index("tuple index out of range".to_string()))
1162}
1163
1164#[cfg(test)]
1165mod currency_tests {
1166 use super::*;
1167 use std::str::FromStr;
1168
1169 /// Drive a `currency:<CODE>` corpus row. The corpus was generated without
1170 /// the `separator=` kwarg, so `None` is passed — exactly as `diff_test.py`
1171 /// does — which must resolve through `default_separator()` to " ".
1172 fn cur(arg: &str, is_int: bool, code: &str) -> String {
1173 let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
1174 LangKy::new()
1175 .to_currency(&v, code, true, None, false)
1176 .unwrap()
1177 }
1178
1179 fn cheque(arg: &str, code: &str) -> Result<String> {
1180 LangKy::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
1181 }
1182
1183 /// Every expectation is copied verbatim from the frozen corpus
1184 /// (`grep '"lang": "ky", "to": "currency' bench/corpus.jsonl`).
1185 #[test]
1186 fn corpus_currency_known_codes() {
1187 assert_eq!(cur("0", true, "EUR"), "нөл евро");
1188 assert_eq!(cur("1", true, "EUR"), "бир евро");
1189 assert_eq!(cur("2", true, "EUR"), "эки евро");
1190 assert_eq!(cur("100", true, "EUR"), "жүз евро");
1191 assert_eq!(cur("1000000", true, "EUR"), "бир миллион евро");
1192 assert_eq!(cur("12.34", false, "EUR"), "он эки евро отуз төрт сент");
1193 assert_eq!(cur("0.01", false, "EUR"), "нөл евро бир сент");
1194 assert_eq!(cur("0.5", false, "EUR"), "нөл евро элүү сент");
1195 assert_eq!(
1196 cur("99.99", false, "EUR"),
1197 "токсон тогуз евро токсон тогуз сент"
1198 );
1199 assert_eq!(
1200 cur("1234.56", false, "EUR"),
1201 "миң эки жүз отуз төрт евро элүү алты сент"
1202 );
1203 assert_eq!(cur("-12.34", false, "EUR"), "минус он эки евро отуз төрт сент");
1204
1205 assert_eq!(cur("0", true, "USD"), "нөл доллар");
1206 assert_eq!(cur("1", true, "USD"), "бир доллар");
1207 assert_eq!(cur("2", true, "USD"), "эки доллар");
1208 assert_eq!(cur("100", true, "USD"), "жүз доллар");
1209 assert_eq!(cur("1000000", true, "USD"), "бир миллион доллар");
1210 assert_eq!(cur("12.34", false, "USD"), "он эки доллар отуз төрт сент");
1211 assert_eq!(cur("0.01", false, "USD"), "нөл доллар бир сент");
1212 assert_eq!(cur("0.5", false, "USD"), "нөл доллар элүү сент");
1213 assert_eq!(
1214 cur("99.99", false, "USD"),
1215 "токсон тогуз доллар токсон тогуз сент"
1216 );
1217 assert_eq!(
1218 cur("1234.56", false, "USD"),
1219 "миң эки жүз отуз төрт доллар элүү алты сент"
1220 );
1221 assert_eq!(
1222 cur("-12.34", false, "USD"),
1223 "минус он эки доллар отуз төрт сент"
1224 );
1225 }
1226
1227 /// `1.0` is a float, so Python reaches `parts[1] == "0"` → `right == 0` →
1228 /// falsy → no cents segment. Same text as the int `1`, by a different path.
1229 #[test]
1230 fn corpus_currency_float_with_zero_cents() {
1231 assert_eq!(cur("1.0", false, "EUR"), "бир евро");
1232 assert_eq!(cur("1.0", false, "USD"), "бир доллар");
1233 assert_eq!(cur("1.0", false, "GBP"), "бир сом");
1234 assert_eq!(cur("1", true, "EUR"), "бир евро");
1235 }
1236
1237 /// Unknown codes silently render as KGS — `.get(currency, values()[0])`,
1238 /// never NotImplementedError. Note JPY still shows subunits and KWD/BHD
1239 /// are plain 100ths: KY never reads CURRENCY_PRECISION.
1240 #[test]
1241 fn corpus_currency_unknown_code_falls_back_to_kgs() {
1242 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1243 assert_eq!(cur("0", true, code), "нөл сом");
1244 assert_eq!(cur("1", true, code), "бир сом");
1245 assert_eq!(cur("2", true, code), "эки сом");
1246 assert_eq!(cur("100", true, code), "жүз сом");
1247 assert_eq!(cur("1000000", true, code), "бир миллион сом");
1248 assert_eq!(cur("12.34", false, code), "он эки сом отуз төрт тыйын");
1249 assert_eq!(cur("0.01", false, code), "нөл сом бир тыйын");
1250 assert_eq!(cur("1.0", false, code), "бир сом");
1251 assert_eq!(cur("0.5", false, code), "нөл сом элүү тыйын");
1252 assert_eq!(
1253 cur("99.99", false, code),
1254 "токсон тогуз сом токсон тогуз тыйын"
1255 );
1256 assert_eq!(
1257 cur("1234.56", false, code),
1258 "миң эки жүз отуз төрт сом элүү алты тыйын"
1259 );
1260 assert_eq!(
1261 cur("-12.34", false, code),
1262 "минус он эки сом отуз төрт тыйын"
1263 );
1264 }
1265 }
1266
1267 /// Not corpus rows — each verified directly against the live interpreter,
1268 /// since the corpus generator never varies these kwargs.
1269 #[test]
1270 fn currency_quirks_verified_against_python() {
1271 let ky = LangKy::new();
1272 let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1273
1274 // cents=False drops the segment entirely; it does NOT fall back to
1275 // `_cents_terse` the way Num2Word_Base would.
1276 assert_eq!(ky.to_currency(&v, "EUR", false, None, false).unwrap(), "он эки евро");
1277
1278 // adjective is accepted and ignored (CURRENCY_ADJECTIVES is empty and
1279 // KY never calls prefix_currency).
1280 assert_eq!(
1281 ky.to_currency(&v, "EUR", true, None, true).unwrap(),
1282 "он эки евро отуз төрт сент"
1283 );
1284
1285 // KY concatenates `separator` with no space of its own, so an explicit
1286 // separator="," closes the gap. Live: 'on eki evro,otuz tört sent'.
1287 assert_eq!(
1288 ky.to_currency(&v, "EUR", true, Some(","), false).unwrap(),
1289 "он эки евро,отуз төрт сент"
1290 );
1291
1292 // parts[1][:2] truncates, never rounds: 12.345 → 34 sent, not 35.
1293 let v = CurrencyValue::parse("12.345", false, true, true).unwrap();
1294 assert_eq!(
1295 ky.to_currency(&v, "EUR", true, None, false).unwrap(),
1296 "он эки евро отуз төрт сент"
1297 );
1298
1299 // Decimal("5.00") → parts[1] == "00" → right == 0 → no cents.
1300 let v = CurrencyValue::parse("5.00", false, true, true).unwrap();
1301 assert_eq!(ky.to_currency(&v, "EUR", true, None, false).unwrap(), "беш евро");
1302 // float 100.0 → parts[1] == "0" → right == 0 → no cents.
1303 let v = CurrencyValue::parse("100.0", false, true, true).unwrap();
1304 assert_eq!(ky.to_currency(&v, "EUR", true, None, false).unwrap(), "жүз евро");
1305 // 0.0001 → parts[1][:2] == "00" → right == 0.
1306 let v = CurrencyValue::parse("0.0001", false, true, true).unwrap();
1307 assert_eq!(ky.to_currency(&v, "EUR", true, None, false).unwrap(), "нөл евро");
1308
1309 // RUB is the one code the corpus never exercises.
1310 let v = CurrencyValue::parse("2.05", false, true, true).unwrap();
1311 assert_eq!(
1312 ky.to_currency(&v, "RUB", true, None, false).unwrap(),
1313 "эки рубль беш копейка"
1314 );
1315
1316 // The 10^9 digit fallback reaches the currency path too.
1317 let v = CurrencyValue::parse("1000000000", true, false, false).unwrap();
1318 assert_eq!(ky.to_currency(&v, "EUR", true, None, false).unwrap(), "1000000000 евро");
1319 }
1320
1321 /// `str(1e+21) == "1e+21"` → `int("1e+21")` → ValueError. The parsed
1322 /// BigDecimal keeps the negative scale, so the notation stays recoverable;
1323 /// this guards the assumption that from_str does not normalise it away.
1324 #[test]
1325 fn currency_exponential_repr_is_valueerror() {
1326 for arg in ["1e+21", "1e+16", "1E+2"] {
1327 let v = CurrencyValue::parse(arg, false, true, true).unwrap();
1328 match LangKy::new().to_currency(&v, "EUR", true, None, false) {
1329 Err(N2WError::Value(_)) => {}
1330 other => panic!("{} expected ValueError, got {:?}", arg, other),
1331 }
1332 }
1333 // Documented divergence: Python raises ValueError for the float 1e-05
1334 // ("1e-05" has no "."), but renders Decimal("0.00001") as "нөл евро".
1335 // Both parse to the same BigDecimal (int_val 1, scale 5), so the two
1336 // are indistinguishable here and we match the Decimal branch. See
1337 // `concerns` in the port report.
1338 assert_eq!(cur("1e-05", false, "EUR"), "нөл евро");
1339 }
1340
1341 /// Inherited `Num2Word_Base.to_cheque` — KY adds no override. The unit is
1342 /// `cr1[-1]` unconditionally, and KY's forms are identical singular/plural.
1343 /// Verbatim from `grep '"lang": "ky", "to": "cheque' bench/corpus.jsonl`.
1344 #[test]
1345 fn corpus_cheque() {
1346 assert_eq!(
1347 cheque("1234.56", "EUR").unwrap(),
1348 "МИҢ ЭКИ ЖҮЗ ОТУЗ ТӨРТ AND 56/100 ЕВРО"
1349 );
1350 assert_eq!(
1351 cheque("1234.56", "USD").unwrap(),
1352 "МИҢ ЭКИ ЖҮЗ ОТУЗ ТӨРТ AND 56/100 ДОЛЛАР"
1353 );
1354 // `self.CURRENCY_FORMS[currency]` → KeyError → NotImplementedError.
1355 // Unlike to_currency, to_cheque has no KGS fallback. This asymmetry is
1356 // the whole reason `currency_forms()` stays a strict lookup.
1357 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1358 match cheque("1234.56", code) {
1359 Err(N2WError::NotImplemented(m)) => assert_eq!(
1360 m,
1361 format!(
1362 "Currency code \"{}\" not implemented for \"Num2Word_KY\"",
1363 code
1364 )
1365 ),
1366 other => panic!("{} expected NotImplementedError, got {:?}", code, other),
1367 }
1368 }
1369 }
1370
1371 /// Not corpus rows — verified live against `Num2Word_KY().to_cheque(...)`.
1372 #[test]
1373 fn cheque_quirks_verified_against_python() {
1374 assert_eq!(
1375 cheque("1234.56", "KGS").unwrap(),
1376 "МИҢ ЭКИ ЖҮЗ ОТУЗ ТӨРТ AND 56/100 СОМ"
1377 );
1378 // Negative → "MINUS " prefix; the whole body is upper-cased.
1379 assert_eq!(cheque("-1.0", "USD").unwrap(), "MINUS БИР AND 00/100 ДОЛЛАР");
1380 }
1381
1382 /// KY's `pluralize` is dead code on every reachable path (`to_currency`
1383 /// indexes the forms literally, `to_cheque` takes `cr1[-1]`), but it is a
1384 /// real override, so its contract is pinned here.
1385 #[test]
1386 fn pluralize_matches_python() {
1387 let ky = LangKy::new();
1388 let forms: Vec<String> = vec!["сом".into(), "сом".into()];
1389 assert_eq!(ky.pluralize(&BigInt::from(1), &forms).unwrap(), "сом");
1390 assert_eq!(ky.pluralize(&BigInt::from(2), &forms).unwrap(), "сом");
1391 // forms[-1], not forms[1] — a three-form entry would take the last.
1392 let three: Vec<String> = vec!["a".into(), "b".into(), "c".into()];
1393 assert_eq!(ky.pluralize(&BigInt::from(2), &three).unwrap(), "c");
1394 // `if not forms: return ""` — cannot raise IndexError, unlike EUR's.
1395 assert_eq!(ky.pluralize(&BigInt::from(2), &[]).unwrap(), "");
1396 }
1397}
1398
1399#[cfg(test)]
1400mod float_tests {
1401 use super::*;
1402 use std::str::FromStr;
1403
1404 /// Drive a `to: "cardinal"` float corpus row. `precision` is the value the
1405 /// Python binding computes (`abs(Decimal(str(value)).as_tuple().exponent)`)
1406 /// and hands across — the number of fractional digits in Python's `repr`.
1407 fn f(value: f64, precision: u32) -> String {
1408 LangKy::new()
1409 .to_cardinal_float(&FloatValue::Float { value, precision }, None)
1410 .unwrap()
1411 }
1412
1413 /// Drive a `to: "cardinal_dec"` Decimal corpus row from its literal string.
1414 /// The output does not depend on the `precision` field (decompose reads the
1415 /// parsed scale), but it is set to the true scale for realism.
1416 fn d(s: &str) -> String {
1417 let value = BigDecimal::from_str(s).unwrap();
1418 let precision = value.as_bigint_and_exponent().1.max(0) as u32;
1419 LangKy::new()
1420 .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1421 .unwrap()
1422 }
1423
1424 /// Every expectation copied verbatim from the frozen corpus
1425 /// (`grep '"lang": "ky", "to": "cardinal", "arg": "[0-9-]*\.' ...`).
1426 #[test]
1427 fn corpus_cardinal_float() {
1428 assert_eq!(f(0.0, 1), "нөл үтүр нөл");
1429 assert_eq!(f(0.5, 1), "нөл үтүр беш");
1430 assert_eq!(f(1.0, 1), "бир үтүр нөл");
1431 assert_eq!(f(1.5, 1), "бир үтүр беш");
1432 assert_eq!(f(2.25, 2), "эки үтүр эки беш");
1433 assert_eq!(f(3.14, 2), "үч үтүр бир төрт");
1434 assert_eq!(f(0.01, 2), "нөл үтүр нөл бир");
1435 assert_eq!(f(0.1, 1), "нөл үтүр бир");
1436 assert_eq!(f(0.99, 2), "нөл үтүр тогуз тогуз");
1437 assert_eq!(f(1.01, 2), "бир үтүр нөл бир");
1438 assert_eq!(f(12.34, 2), "он эки үтүр үч төрт");
1439 assert_eq!(f(99.99, 2), "токсон тогуз үтүр тогуз тогуз");
1440 assert_eq!(f(100.5, 1), "жүз үтүр беш");
1441 assert_eq!(f(1234.56, 2), "миң эки жүз отуз төрт үтүр беш алты");
1442 assert_eq!(f(-0.5, 1), "минус нөл үтүр беш");
1443 assert_eq!(f(-1.5, 1), "минус бир үтүр беш");
1444 assert_eq!(f(-12.34, 2), "минус он эки үтүр үч төрт");
1445 }
1446
1447 /// The two f64-artefact cases. KY sidesteps `float2tuple`'s
1448 /// `674.9999999999998` heuristic entirely: `str(2.675) == "2.675"` and
1449 /// `str(1.005) == "1.005"`, and `{:.precision}` reproduces those clean
1450 /// shortest-repr strings, so no rounding rescue is ever needed.
1451 #[test]
1452 fn corpus_cardinal_float_artefacts() {
1453 assert_eq!(f(1.005, 3), "бир үтүр нөл нөл беш");
1454 assert_eq!(f(2.675, 3), "эки үтүр алты жети беш");
1455 }
1456
1457 /// `precision_override` is inert for KY (its `to_cardinal` never consults
1458 /// `self.precision`). Verified live: all three digits survive regardless.
1459 #[test]
1460 fn precision_override_is_ignored() {
1461 let ky = LangKy::new();
1462 let v = FloatValue::Float { value: 2.675, precision: 3 };
1463 assert_eq!(ky.to_cardinal_float(&v, Some(1)).unwrap(), "эки үтүр алты жети беш");
1464 assert_eq!(ky.to_cardinal_float(&v, Some(5)).unwrap(), "эки үтүр алты жети беш");
1465 }
1466
1467 /// Every expectation copied verbatim from the frozen corpus
1468 /// (`grep '"lang": "ky", "to": "cardinal_dec"' ...`). The Decimal arm keeps
1469 /// trailing zeros ("1.10" → "бир үтүр бир нөл"), renders the full fractional
1470 /// part with no `[:2]` truncation ("12.345" → three digits), and inherits
1471 /// the `>= 10^9` digit fallback on the integer part.
1472 #[test]
1473 fn corpus_cardinal_dec() {
1474 assert_eq!(d("0.01"), "нөл үтүр нөл бир");
1475 assert_eq!(d("1.10"), "бир үтүр бир нөл");
1476 assert_eq!(d("12.345"), "он эки үтүр үч төрт беш");
1477 assert_eq!(d("98746251323029.99"), "98746251323029 үтүр тогуз тогуз");
1478 assert_eq!(d("0.001"), "нөл үтүр нөл нөл бир");
1479 }
1480
1481 /// Not corpus rows — verified live against `num2words(Decimal(...), 'ky')`.
1482 /// A Decimal whose fractional part is all zeros still spells them out
1483 /// (unlike the currency path, where `right == 0` suppresses the segment),
1484 /// because KY reads the *string* form, not a numeric magnitude.
1485 #[test]
1486 fn cardinal_dec_quirks_verified_against_python() {
1487 assert_eq!(d("5.00"), "беш үтүр нөл нөл");
1488 }
1489}
1490
1491#[cfg(test)]
1492mod entry_routing_tests {
1493 use super::*;
1494 use std::str::FromStr;
1495
1496 fn fv(value: f64, precision: u32) -> FloatValue {
1497 FloatValue::Float { value, precision }
1498 }
1499
1500 fn dv(s: &str) -> FloatValue {
1501 let value = BigDecimal::from_str(s).unwrap();
1502 let precision = value.as_bigint_and_exponent().1.max(0) as u32;
1503 FloatValue::Decimal { value, precision }
1504 }
1505
1506 /// `"." in str(number)` routing — corpus_wholefloat rows, verbatim.
1507 #[test]
1508 fn corpus_cardinal_entry() {
1509 let ky = LangKy::new();
1510 // Whole floats keep their ".0" tail.
1511 assert_eq!(ky.cardinal_float_entry(&fv(5.0, 1), None).unwrap(), "беш үтүр нөл");
1512 assert_eq!(ky.cardinal_float_entry(&fv(0.0, 1), None).unwrap(), "нөл үтүр нөл");
1513 assert_eq!(
1514 ky.cardinal_float_entry(&fv(-1000000.0, 1), None).unwrap(),
1515 "минус бир миллион үтүр нөл"
1516 );
1517 // -0.0: the sign lives in the *string*, so the negword survives.
1518 assert_eq!(
1519 ky.cardinal_float_entry(&fv(-0.0, 1), None).unwrap(),
1520 "минус нөл үтүр нөл"
1521 );
1522 // The >= 10^9 digit fallback composes with the ".0" tail.
1523 assert_eq!(
1524 ky.cardinal_float_entry(&fv(1e9, 1), None).unwrap(),
1525 "1000000000 үтүр нөл"
1526 );
1527 // Decimals: trailing zeros of the literal are all spelled.
1528 assert_eq!(ky.cardinal_float_entry(&dv("5.00"), None).unwrap(), "беш үтүр нөл нөл");
1529 assert_eq!(
1530 ky.cardinal_float_entry(&dv("12345.000"), None).unwrap(),
1531 "он эки миң үч жүз кырк беш үтүр нөл нөл нөл"
1532 );
1533 // Point-less integral Decimals take the integer grammar.
1534 assert_eq!(ky.cardinal_float_entry(&dv("0"), None).unwrap(), "нөл");
1535 assert_eq!(ky.cardinal_float_entry(&dv("100"), None).unwrap(), "жүз");
1536 }
1537
1538 /// Scientific repr has no "." -> `int(n)` ValueError, message verbatim.
1539 #[test]
1540 fn corpus_cardinal_entry_scientific_is_valueerror() {
1541 let ky = LangKy::new();
1542 for (v, repr) in [(1e16, "1e+16"), (1e20, "1e+20"), (1e-5, "1e-05")] {
1543 match ky.cardinal_float_entry(&fv(v, 0), None) {
1544 Err(N2WError::Value(m)) => assert_eq!(
1545 m,
1546 format!("invalid literal for int() with base 10: '{}'", repr)
1547 ),
1548 other => panic!("{} expected ValueError, got {:?}", v, other),
1549 }
1550 }
1551 // Negative: the string sign is stripped before int(), so no minus.
1552 match ky.cardinal_float_entry(&fv(-1e16, 0), None) {
1553 Err(N2WError::Value(m)) => {
1554 assert_eq!(m, "invalid literal for int() with base 10: '1e+16'")
1555 }
1556 other => panic!("expected ValueError, got {:?}", other),
1557 }
1558 for (s, repr) in [("1E+2", "1E+2"), ("1E+20", "1E+20")] {
1559 match ky.cardinal_float_entry(&dv(s), None) {
1560 Err(N2WError::Value(m)) => assert_eq!(
1561 m,
1562 format!("invalid literal for int() with base 10: '{}'", repr)
1563 ),
1564 other => panic!("{} expected ValueError, got {:?}", s, other),
1565 }
1566 }
1567 }
1568
1569 /// `to_ordinal(float)` = cardinal + the harmonised ending; errors
1570 /// propagate unchanged.
1571 #[test]
1572 fn corpus_ordinal_entry() {
1573 let ky = LangKy::new();
1574 assert_eq!(
1575 ky.ordinal_float_entry(&fv(5.0, 1)).unwrap(),
1576 "беш үтүр нөлүнчү"
1577 );
1578 assert_eq!(
1579 ky.ordinal_float_entry(&fv(-0.0, 1)).unwrap(),
1580 "минус нөл үтүр нөлүнчү"
1581 );
1582 assert_eq!(
1583 ky.ordinal_float_entry(&fv(3.25, 2)).unwrap(),
1584 "үч үтүр эки бешинчи"
1585 );
1586 assert_eq!(ky.ordinal_float_entry(&dv("0")).unwrap(), "нөлүнчү");
1587 assert_eq!(
1588 ky.ordinal_float_entry(&dv("5.00")).unwrap(),
1589 "беш үтүр нөл нөлүнчү"
1590 );
1591 assert!(matches!(
1592 ky.ordinal_float_entry(&fv(1e16, 0)),
1593 Err(N2WError::Value(_))
1594 ));
1595 }
1596
1597 /// `to_ordinal_num(float)` = `str(number)` + "-инчи" — nothing parses,
1598 /// so the scientific forms succeed here.
1599 #[test]
1600 fn corpus_ordinal_num_entry() {
1601 let ky = LangKy::new();
1602 assert_eq!(
1603 ky.ordinal_num_float_entry(&fv(5.0, 1), "5.0").unwrap(),
1604 "5.0-инчи"
1605 );
1606 assert_eq!(
1607 ky.ordinal_num_float_entry(&fv(-0.0, 1), "-0.0").unwrap(),
1608 "-0.0-инчи"
1609 );
1610 assert_eq!(
1611 ky.ordinal_num_float_entry(&fv(1e16, 0), "1e+16").unwrap(),
1612 "1e+16-инчи"
1613 );
1614 assert_eq!(
1615 ky.ordinal_num_float_entry(&dv("1E+2"), "1E+2").unwrap(),
1616 "1E+2-инчи"
1617 );
1618 assert_eq!(
1619 ky.ordinal_num_float_entry(&dv("5.00"), "5.00").unwrap(),
1620 "5.00-инчи"
1621 );
1622 }
1623
1624 /// `to_year(float)` — the trait default routes through the overridden
1625 /// `cardinal_float_entry`, giving KY's plain-cardinal years.
1626 #[test]
1627 fn corpus_year_entry() {
1628 let ky = LangKy::new();
1629 assert_eq!(ky.year_float_entry(&fv(5.0, 1)).unwrap(), "беш үтүр нөл");
1630 assert_eq!(
1631 ky.year_float_entry(&fv(-0.0, 1)).unwrap(),
1632 "минус нөл үтүр нөл"
1633 );
1634 assert!(matches!(
1635 ky.year_float_entry(&fv(1e20, 0)),
1636 Err(N2WError::Value(_))
1637 ));
1638 }
1639
1640 /// "Infinity"/"NaN" strings fall back to Python (NotImplemented), which
1641 /// reproduces the per-mode split (`cardinal` ValueError vs `ordinal_num`
1642 /// "Infinity-инчи") that `ParsedNumber` cannot carry.
1643 #[test]
1644 fn str_to_number_inf_nan_falls_through() {
1645 let ky = LangKy::new();
1646 // Inf/NaN are now carried through natively (see `inf_result`/
1647 // `nan_result`), so str_to_number succeeds rather than declining.
1648 for s in ["Infinity", "-Infinity", "NaN"] {
1649 assert!(ky.str_to_number(s).is_ok());
1650 }
1651 // Everything else keeps Base's Decimal(value) semantics.
1652 assert!(matches!(ky.str_to_number("5"), Ok(ParsedNumber::Dec(_))));
1653 assert!(matches!(ky.str_to_number("1e3"), Ok(ParsedNumber::Dec(_))));
1654 }
1655
1656 /// String inputs land on the same entries via Decimal: "1e3" parses to
1657 /// Decimal("1E+3"), whose point-less non-integer str raises int()'s
1658 /// ValueError — the corpus_strings rows for "1e3"/"1E3".
1659 #[test]
1660 fn corpus_strings_scientific_decimal_is_valueerror() {
1661 let ky = LangKy::new();
1662 match ky.cardinal_float_entry(&dv("1E+3"), None) {
1663 Err(N2WError::Value(m)) => {
1664 assert_eq!(m, "invalid literal for int() with base 10: '1E+3'")
1665 }
1666 other => panic!("expected ValueError, got {:?}", other),
1667 }
1668 }
1669}