num2words2_core/lang_ckb.rs
1//! Port of `lang_CKB.py` (Central Kurdish / Sorani).
2//!
3//! # Deliberate divergence from upstream: the script
4//!
5//! `lang_CKB.py` spells every numeral in **Latin transliteration** ("yek",
6//! "çwar", "hezar", "sed"). Central Kurdish is written in the Perso-Arabic
7//! Sorani alphabet; the Latin (Hawar) alphabet is Kurmanji's, and Kurmanji
8//! already has its own converter in this crate (`lang_ku.rs`), which is where
9//! Latin-script Kurdish output belongs. The tables here are the Sorani
10//! spellings: "یەک", "چوار", "هەزار", "سەد".
11//!
12//! This is a lexicon change only — the composition rules, the multiplier
13//! guards, the 10^9 digit cliff and the currency fallback are all still
14//! ported verbatim, bug for bug, and `verify_ordinal` is still not called.
15//!
16//! ## What is *not* fixed: the ordinal after a vowel
17//!
18//! ـەم is the genuine Sorani ordinal marker, and gluing it on is already
19//! right after a consonant — which is most of the lexicon (یەکەم, چوارەم,
20//! پێنجەم, شەشەم, حەوتەم, هەشتەم, بیستەم, چلەم, شەستەم, نەوەدەم, سەدەم,
21//! هەزارەم, ملیۆنەم). After a **vowel-final** stem standard Sorani inserts a
22//! linking consonant, and the sources disagree on which one: Omniglot writes
23//! دووهەم / سێهەم / نۆهەم / دەهەم while other references write سێیەم /
24//! نۆیەم / دەیەم. The vowel-final stems in these tables are سێ, نۆ, دە,
25//! پەنجا, حەفتا, هەشتا and every ـە-final teen.
26//!
27//! Both variants are attested and choosing between them needs a native
28//! speaker, so this module keeps upstream's blind suffix there rather than
29//! guessing. `to_ordinal(3)` is therefore سێەم, which is wrong but no more
30//! wrong than it was; it is tracked separately from the script fix.
31//!
32//! Registry check: `CONVERTER_CLASSES["ckb"]` is `lang_CKB.Num2Word_CKB()`
33//! (`__init__.py:381`), which is the class ported here.
34//!
35//! Shape: **self-contained**. `Num2Word_CKB` subclasses `Num2Word_Base` but
36//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
37//! enters the `__init__` branch that builds `self.cards` / `set_numwords()` /
38//! `self.MAXVAL`. Both `self.cards` and `self.MAXVAL` therefore **never exist**
39//! on a CKB instance. `to_cardinal` is overridden outright and drives a
40//! recursive `_int_to_word`, so `cards`/`maxval`/`merge` stay at their trait
41//! defaults here and are never consulted. There is **no overflow check** and
42//! no `OverflowError` path — see bug 1 below for what happens instead.
43//!
44//! Inherited from `Num2Word_Base`, but every one of them is overridden by CKB,
45//! so nothing is picked up from the base class in the four modes in scope:
46//! * `to_cardinal`, `to_ordinal`, `to_ordinal_num`, `to_year` — all defined
47//! in `lang_CKB.py` itself.
48//!
49//! # The float/Decimal path
50//!
51//! CKB does **not** override `to_cardinal_float`, and it never reaches
52//! `Num2Word_Base.to_cardinal_float` either: `num2words()` dispatches every
53//! non-integer straight to `converter.to_cardinal(number)`, and CKB's override
54//! swallows floats itself. So `base.float2tuple` — and with it the entire f64
55//! `post = abs(value - pre) * 10**precision` / `< 0.01` heuristic that
56//! `floatpath.rs` exists to reproduce — **is never executed for CKB**.
57//!
58//! CKB's first statement is `n = str(number).strip()`, and everything after it
59//! is string surgery on that repr. The consequences run the opposite way to the
60//! base class's:
61//!
62//! * **No f64 artefacts.** `repr` is shortest-round-trip, so `2.675` spells
63//! `"2.675"` and `1.005` spells `"1.005"` — the exact cases where
64//! `float2tuple` produces `674.9999999999998` and needs rescuing. Here the
65//! noise never appears, so there is nothing to rescue and no `round()` to get
66//! banker's-wrong. Corpus confirms both: `2.675` → `"دوو خاڵ شەش حەوت پێنج"`.
67//! * **No rounding, no padding, no `precision`.** The fraction is however many
68//! digits the repr happened to carry, one word each. `Decimal("1.10")` keeps
69//! its written trailing zero (`"یەک خاڵ یەک سفر"`) where the float `1.1`
70//! could not — the `Decimal`/`float` split is *visible* in the output, not
71//! just in the last bits.
72//! * **`precision=` is inert** — see [`LangCkb::to_cardinal_float`].
73//! * **An exponential repr is a `ValueError`, not a number** — see bug 10.
74//!
75//! `Num2Word_CKB.to_cardinal` is byte-for-byte `Num2Word_CEB.to_cardinal` with
76//! `"siro"` swapped for `"سفر"`, so [`LangCkb::cardinal_from_repr`] and
77//! `lang_ceb.rs`'s `cardinal_from_repr` are the same port. The two crates'
78//! copies of [`py_str_f64`] / [`py_str_decimal`] are deliberate duplicates: a
79//! shared helper would have to live in `base.rs` or `floatpath.rs`, which this
80//! phase may not touch. See `concerns`.
81//!
82//! `Num2Word_Base.to_cardinal` would have called `self.title(...)`; CKB's
83//! override does **not**, so `is_title`/`exclude_title` are inert for the four
84//! modes in scope. `setup()`'s `exclude_title = ["و", "خاڵ", "نێگەتیڤ"]` is
85//! mirrored on the struct for fidelity but is never read: `is_title` stays
86//! `false` (set in `Num2Word_Base.__init__`) and `title` is never reached.
87//!
88//! # Faithfully reproduced Python bugs
89//!
90//! This is a port, not a rewrite. The following look wrong but are exactly
91//! what Python emits, and are confirmed by the frozen corpus:
92//!
93//! 1. **`_int_to_word` gives up at 10^9 and returns the decimal digits.** The
94//! final line of the if-chain is a bare `return str(number)` — no billion
95//! word exists in the table (`setup` stops at `million = "ملیۆن"`). So
96//! `to_cardinal(10**9)` == `"1000000000"`, and `to_ordinal(10**9)` ==
97//! `"1000000000ەم"` — digits with a word suffix glued on. Corpus rows for
98//! 10^9, 1234567890, 10^10, 10^11, 10^12, 10^15, 10^18 and 10^21 all
99//! confirm this. It is unbounded: no exception is ever raised, however
100//! large the input. Modelled in [`LangCkb::int_to_word`].
101//! Negatives compose with it: `to_cardinal(-10**9)` == `"نێگەتیڤ 1000000000"`.
102//! 2. **`tens[1] == "دە"` is dead.** 10..=19 are caught by the `number < 20`
103//! branch and served from `teens`, so the `< 100` branch never divides down
104//! to a tens index of 1. The entry is kept verbatim in [`TENS`] anyway.
105//! 3. **`ones[0] == ""` is dead** on the integer path — `_int_to_word` handles
106//! 0 before the `number < 10` branch. Python only reads `ones[0]` from the
107//! float branch, where `self.ones[int(digit)] or "سفر"` relies on the
108//! empty string being *falsy* to turn a fraction digit 0 into a word. That
109//! is a different mechanism from `_int_to_word`'s explicit `if number == 0`;
110//! both are live and both are needed. Corpus: `1.0` → `"یەک خاڵ سفر"`.
111//! 4. **`to_ordinal` is pure concatenation**: `to_cardinal(n) + ەم`, with no
112//! joint and no stem change. So the suffix lands on the *last* word only:
113//! `to_ordinal(999)` == `"نۆ سەد و نەوەد و نۆەم"`, and `to_ordinal(-1)` ==
114//! `"نێگەتیڤ یەکەم"`. Unlike most languages there is no negative-ordinal
115//! guard, so no `ValueError`/`TypeError` for negative ordinals.
116//! 5. **`_int_to_word` would index `ones`/`teens` with a negative index** if it
117//! were ever handed a negative number (Python's negative indexing wraps, so
118//! `ones[-5]` would silently yield `"پێنج"`). It is unreachable: the only
119//! two callers, `to_cardinal` and `to_currency`, strip the sign / take
120//! `abs()` first. [`LangCkb::int_to_word`] is only ever called with a
121//! non-negative value here, mirroring that. The float path does not change
122//! this: `cardinal_from_repr` peels the `"-"` off the *string* before any
123//! `int()` runs, so `left` is always non-negative too.
124//! 10. **A float whose repr goes exponential is a `ValueError`, not a number.**
125//! Nothing filters the repr before it reaches `int()`, so every value
126//! CPython spells with an `e` crashes — and *where* it crashes depends on
127//! whether the mantissa has a fractional part, which changes the message:
128//! * `1e16` → repr `"1e+16"` → no `"."` → `int("1e+16")` raises
129//! `ValueError: invalid literal for int() with base 10: '1e+16'`.
130//! * `1.5e16` → repr `"1.5e+16"` → splits to `left="1"`, `right="5e+16"`;
131//! `"یەک خاڵ پێنج"` is built successfully and then thrown away when the
132//! loop hits `int('e')` → `ValueError: invalid literal for int() with
133//! base 10: 'e'`. The offending token is the single *character*, not the
134//! whole fragment.
135//! * `1e-05` (i.e. any `abs(v) < 1e-4`) → `"1e-05"` → same as `1e16`.
136//! * `inf` / `nan` → `int("inf")` / `int("nan")` → same shape.
137//!
138//! So CKB's float path is only total on `-4 < decpt <= 16`. Both messages
139//! are reproduced verbatim; see [`py_int`] and [`py_int_digit`].
140//!
141//! # Error variants
142//!
143//! `N2WError::Value` (Python `ValueError`), and only from the float path —
144//! bug 10's `int()` failures on an exponential/non-finite repr. Every *integer*
145//! input in the four modes in scope still returns `Ok`: CKB has no overflow
146//! ceiling (bug 1), no negative-ordinal guard (bug 4) and no dict lookups that
147//! can miss, and `str` of a `BigInt` is never exponential. The 20 non-`ok`
148//! corpus rows for `ckb` are all `cheque:*` (`NotImplementedError`) and
149//! `fraction` (`TypeError`), both out of scope.
150
151use crate::base::{Lang, N2WError, Result};
152use crate::currency::{CurrencyForms, CurrencyValue};
153use crate::floatpath::FloatValue;
154use crate::strnum::{python_decimal_parse, ParsedNumber};
155use bigdecimal::BigDecimal;
156use num_bigint::BigInt;
157use num_traits::{One, Signed, ToPrimitive, Zero};
158use std::collections::HashMap;
159use std::str::FromStr;
160
161/// `setup`: `self.negword`. Note the **trailing space** — CKB's `to_cardinal`
162/// concatenates it directly (`self.negword + ...`) rather than joining with a
163/// separator, so the space is what separates "نێگەتیڤ" from the number.
164const NEGWORD: &str = "نێگەتیڤ ";
165
166/// `setup`: `self.pointword`. Float path only; inert for the modes in scope.
167const POINTWORD: &str = "خاڵ";
168
169/// `_int_to_word`'s zero word. Not in `ones` — `ones[0]` is `""` (bug 3).
170const ZERO: &str = "سفر";
171
172/// `setup`: `self.ones`. Index 0 is `""` and dead on the integer path (bug 3).
173const ONES: [&str; 10] = [
174 "", "یەک", "دوو", "سێ", "چوار", "پێنج", "شەش", "حەوت", "هەشت", "نۆ",
175];
176
177/// `setup`: `self.teens`, indexed by `number - 10` for 10..=19.
178const TEENS: [&str; 10] = [
179 "دە", "یانزە", "دوانزە", "سیانزە", "چواردە", "پانزە", "شانزدە", "حەڤدە", "هەژدە", "نۆزدە",
180];
181
182/// `setup`: `self.tens`, indexed by `number // 10`. Index 0 is `""` (dead: the
183/// `< 100` branch is only reached for `number >= 20`) and index 1 is `"دە"`
184/// (also dead — see bug 2).
185const TENS: [&str; 10] = [
186 "", "دە", "بیست", "سی", "چل", "پەنجا", "شەست", "حەفتا", "هەشتا", "نەوەد",
187];
188
189/// `setup`: `self.hundred`.
190const HUNDRED: &str = "سەد";
191/// `setup`: `self.thousand`.
192const THOUSAND: &str = "هەزار";
193/// `setup`: `self.million`. The largest scale word CKB defines — hence bug 1.
194const MILLION: &str = "ملیۆن";
195
196/// The joint between every pair of groups: `" و "` ("and").
197const JOINT: &str = " و ";
198
199/// `to_ordinal` / `to_ordinal_num` suffix, glued on with no separator.
200///
201/// ـەم is the real Sorani ordinal marker and is correct as-is after a
202/// consonant, which covers most of the lexicon: یەکەم, چوارەم, پێنجەم,
203/// شەشەم, حەوتەم, هەشتەم, بیستەم, چلەم, شەستەم, نەوەدەم, سەدەم, هەزارەم,
204/// ملیۆنەم.
205///
206/// **Known gap, deliberately not fixed here.** After a *vowel-final* stem
207/// standard Sorani inserts a linking consonant, and the sources disagree on
208/// which: Omniglot writes دووهەم / سێهەم / نۆهەم / دەهەم (ـهەم) while other
209/// references write سێیەم / نۆیەم / دەیەم (ـیەم). Both are attested. The
210/// vowel-final stems in these tables are سێ, نۆ, دە, پەنجا, حەفتا, هەشتا and
211/// every ـە-final teen. Picking between the two variants needs a native
212/// speaker, so this module keeps upstream's blind suffix there rather than
213/// guessing; see the module header.
214const ORDINAL_SUFFIX: &str = "ەم";
215
216/// The ceiling of `_int_to_word`'s word-producing branches. At or above this,
217/// Python falls through to `return str(number)` (bug 1).
218const BILLION: u64 = 1_000_000_000;
219
220/// `self.__class__.__name__`, for `to_cheque`'s NotImplementedError message.
221const LANG_NAME: &str = "Num2Word_CKB";
222
223/// The subunit divisor CKB's `to_currency` **hardcodes**.
224///
225/// `Num2Word_CKB.to_currency` reads exactly two fractional digits —
226/// `int(parts[1][:2].ljust(2, "0"))` — and never consults
227/// `CURRENCY_PRECISION`. So every code is 2-decimal on the `to_currency` path,
228/// including the 3-decimal (KWD/BHD) and 0-decimal (JPY) ones. See bug 8.
229///
230/// This is *not* `currency_precision`, which stays at the trait default of 100
231/// because `Num2Word_CKB` inherits `Num2Word_Base.CURRENCY_PRECISION == {}`
232/// (verified live: it is still the unmutated base dict). The two happen to
233/// agree at 100, but they are independent knobs — `to_cheque` reads the former,
234/// `to_currency` this one.
235const CENTS_PER_UNIT: i64 = 100;
236
237/// The code whose forms `to_currency` falls back to for an unknown currency.
238///
239/// Python writes `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
240/// — the *first inserted* entry, since dicts preserve insertion order. The
241/// class body lists IQD first, so the fallback is IQD. Verified live:
242/// `list(CURRENCY_FORMS.keys()) == ['IQD', 'IRR', 'USD', 'EUR']`. See bug 7.
243const FALLBACK_CURRENCY: &str = "IQD";
244
245pub struct LangCkb {
246 /// `setup`: `self.exclude_title`. Stored to mirror `setup()` faithfully;
247 /// never read, because CKB's `to_cardinal` override never calls `title`.
248 exclude_title: Vec<String>,
249 /// `Num2Word_CKB.CURRENCY_FORMS`, built once (see [`LangCkb::new`]).
250 ///
251 /// A plain class attribute on `Num2Word_CKB` itself, so — unlike the
252 /// `Num2Word_EUR` dict that `Num2Word_EN.__init__` rewrites in place — no
253 /// other language's `__init__` can reach it. Verified live: the four
254 /// literals in the class body are exactly what runs.
255 currency_forms: HashMap<&'static str, CurrencyForms>,
256 /// The [`FALLBACK_CURRENCY`] entry, cloned out at construction so the
257 /// unknown-code path in `to_currency` is a field read rather than a lookup
258 /// that could fail.
259 fallback_forms: CurrencyForms,
260}
261
262impl Default for LangCkb {
263 fn default() -> Self {
264 Self::new()
265 }
266}
267
268impl LangCkb {
269 /// Builds the `CURRENCY_FORMS` table **once**. `to_currency` / `to_cheque`
270 /// only ever read it; nothing is constructed per call.
271 pub fn new() -> Self {
272 // Insertion order mirrors the class body. The map itself is unordered,
273 // so the "first entry" fallback is pinned by FALLBACK_CURRENCY instead.
274 let iqd = CurrencyForms::new(&["دینار", "دینار"], &["فلس", "فلس"]);
275 let mut currency_forms = HashMap::new();
276 currency_forms.insert(FALLBACK_CURRENCY, iqd.clone());
277 currency_forms.insert("IRR", CurrencyForms::new(&["ریاڵ", "ریاڵ"], &["دینار", "دینار"]));
278 currency_forms.insert("USD", CurrencyForms::new(&["دۆلار", "دۆلار"], &["سەنت", "سەنت"]));
279 currency_forms.insert("EUR", CurrencyForms::new(&["یۆرۆ", "یۆرۆ"], &["سەنت", "سەنت"]));
280
281 LangCkb {
282 exclude_title: vec!["و".to_string(), "خاڵ".to_string(), "نێگەتیڤ".to_string()],
283 currency_forms,
284 fallback_forms: iqd,
285 }
286 }
287
288 /// Python's `parts = str(abs(val)).split(".")` → `(left, right)`.
289 ///
290 /// ```python
291 /// left = int(parts[0]) if parts[0] else 0
292 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
293 /// ```
294 ///
295 /// Reproduced structurally on the parsed value rather than by re-rendering
296 /// a string, because `BigDecimal`'s `Display` is not `str(float)`:
297 ///
298 /// * `left` is `str(...)` split at the dot, i.e. the digits before it —
299 /// truncation toward zero of a non-negative value. `with_scale(0)` is
300 /// exactly that (bigdecimal 0.4.10 implements it with `BigInt` division,
301 /// which truncates; `with_scale_round` is the rounding one).
302 /// * `right` is `parts[1][:2]` right-padded to two digits. Slicing a digit
303 /// string to two chars *truncates* the rest and padding supplies missing
304 /// digits as zeros, which together are exactly
305 /// `trunc(frac * 100)`: `1.005` → `"005"[:2]` → `"00"` → `0`, and
306 /// `trunc(0.005 * 100) == trunc(0.5) == 0`. `2.675` → `"67"` → `67`, and
307 /// `trunc(67.5) == 67`. **Not** rounding — `1.005` yields 0 cents, not 1.
308 ///
309 /// A `CurrencyValue::Int` stringifies without a dot, so `parts` has length
310 /// 1 and `right` is 0 — an int can never show cents. A float that happens
311 /// to be whole (`1.0`) takes the `Decimal` arm, but `str(1.0) == "1.0"`
312 /// gives `parts[1] == "0"` → `int("00")` → 0, so it lands on zero cents too
313 /// and `if cents and right:` skips the segment either way. The two arms
314 /// therefore agree here, unlike in `Num2Word_Base.to_currency`. Corpus:
315 /// both `1` and `1.0` render `"یەک یۆرۆ"`.
316 fn currency_parts(&self, val: &CurrencyValue) -> (BigInt, BigInt) {
317 match val {
318 CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
319 CurrencyValue::Decimal { value: d, .. } => {
320 let abs = d.abs();
321 let left = abs.with_scale(0);
322 let fraction = &abs - &left;
323 let right = (fraction * BigDecimal::from(CENTS_PER_UNIT)).with_scale(0);
324 (
325 left.as_bigint_and_exponent().0,
326 right.as_bigint_and_exponent().0,
327 )
328 }
329 }
330 }
331
332 /// Port of `Num2Word_CKB._int_to_word`.
333 ///
334 /// Only ever called with a **non-negative** value (see bug 5): `to_cardinal`
335 /// strips the `"-"` before recursing.
336 ///
337 /// Values `>= 10^9` take Python's final `return str(number)` — the decimal
338 /// digits, not words (bug 1). That guard is hoisted here so the recursive
339 /// worker can run on `u64`: below 10^9 every value, and every quotient and
340 /// remainder derived from it, provably fits.
341 fn int_to_word(&self, number: &BigInt) -> String {
342 match number.to_u64() {
343 Some(n) if n < BILLION => int_to_word_small(n),
344 // >= 10^9 (or, unreachably, negative): Python's `return str(number)`.
345 _ => number.to_string(),
346 }
347 }
348
349 /// Port of `Num2Word_CKB.to_cardinal` operating on the repr, which is the
350 /// form Python's own code works in:
351 ///
352 /// ```python
353 /// n = str(number).strip()
354 /// if n.startswith("-"):
355 /// return (self.negword + self.to_cardinal(n[1:])).strip()
356 /// if "." in n:
357 /// left, right = n.split(".", 1)
358 /// ret = self._int_to_word(int(left)) + " " + self.pointword
359 /// for digit in right:
360 /// ret += " " + (self.ones[int(digit)] or "سفر")
361 /// return ret.strip()
362 /// return self._int_to_word(int(n))
363 /// ```
364 ///
365 /// The recursion really is string-level in Python — `to_cardinal(n[1:])`
366 /// hands a `str` back to the same method, whose `str(number)` is then a
367 /// no-op — so it is reproduced as such rather than folded into an `abs()`.
368 /// One level deep in practice: no repr yields a second leading `"-"`.
369 ///
370 /// Note this is the *whole* method, integer inputs included. The
371 /// [`Lang::to_cardinal`] impl above is the same algorithm specialised to a
372 /// `BigInt`, where `str` can never contain a `"."` or an `"e"` and the two
373 /// `int()` calls cannot fail; both are kept because the trait splits the
374 /// integer and float entry points that Python fuses into one.
375 ///
376 /// Two details that look like slips but are Python's:
377 ///
378 /// * `split(".", 1)` splits on the *first* dot only, so a second dot would
379 /// land in `right` and reach `int()` as a character. Unreachable from a
380 /// repr.
381 /// * `self.ones[0]` is `""`, which is falsy, so `or "سفر"` is what turns
382 /// a fraction digit 0 into a word (bug 3).
383 fn cardinal_from_repr(&self, n: &str) -> Result<String> {
384 // n = str(number).strip(). Python strips its own whitespace set and
385 // Rust trims Unicode's; no repr contains either.
386 let n = n.trim();
387
388 // if n.startswith("-"): return (self.negword + self.to_cardinal(n[1:])).strip()
389 if let Some(rest) = n.strip_prefix('-') {
390 let inner = self.cardinal_from_repr(rest)?;
391 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
392 }
393
394 let (left, right) = match n.split_once('.') {
395 Some(halves) => halves,
396 // return self._int_to_word(int(n))
397 None => return Ok(self.int_to_word(&py_int(n)?)),
398 };
399
400 // ret = self._int_to_word(int(left)) + " " + self.pointword
401 //
402 // `int(left)` is the whole integer part, so bug 1 applies at 10^9 and
403 // above: it comes back as bare digits ("98746251323029 خاڵ نۆ نۆ").
404 let mut ret = self.int_to_word(&py_int(left)?);
405 ret.push(' ');
406 ret.push_str(POINTWORD);
407
408 // for digit in right: ret += " " + (self.ones[int(digit)] or "سفر")
409 //
410 // Per *character*, so there is no grouping, no rounding and no padding:
411 // the fraction is however many digits the repr had. The partially built
412 // `ret` is discarded if a later character is not a digit (bug 10).
413 for digit in right.chars() {
414 let word = ONES[py_int_digit(digit)?];
415 ret.push(' ');
416 ret.push_str(if word.is_empty() { ZERO } else { word });
417 }
418
419 // Cosmetic: `_int_to_word` never returns "", so nothing can be trimmed.
420 Ok(ret.trim().to_string())
421 }
422}
423
424/// The `< 10^9` body of `_int_to_word`, on `u64`.
425///
426/// The branch order mirrors Python's if-chain exactly. `divmod` on
427/// non-negative operands is the same in both languages, so `/` and `%` are
428/// direct equivalents here (no floor-division discrepancy to worry about —
429/// the value is never negative).
430fn int_to_word_small(number: u64) -> String {
431 if number == 0 {
432 return ZERO.to_string();
433 }
434 if number < 10 {
435 return ONES[number as usize].to_string();
436 }
437 if number < 20 {
438 return TEENS[(number - 10) as usize].to_string();
439 }
440 if number < 100 {
441 // t, o = divmod(number, 10)
442 let (t, o) = (number / 10, number % 10);
443 let mut out = TENS[t as usize].to_string();
444 if o != 0 {
445 out.push_str(JOINT);
446 out.push_str(ONES[o as usize]);
447 }
448 return out;
449 }
450 if number < 1_000 {
451 // h, r = divmod(number, 100)
452 let (h, r) = (number / 100, number % 100);
453 // base = (self.ones[h] + " " if h > 1 else "") + self.hundred
454 // h == 1 is bare "سەد" — no "یەک سەد". Corpus: 100 -> "سەد".
455 let mut out = String::new();
456 if h > 1 {
457 out.push_str(ONES[h as usize]);
458 out.push(' ');
459 }
460 out.push_str(HUNDRED);
461 if r != 0 {
462 out.push_str(JOINT);
463 out.push_str(&int_to_word_small(r));
464 }
465 return out;
466 }
467 if number < 1_000_000 {
468 // t, r = divmod(number, 1000)
469 let (t, r) = (number / 1_000, number % 1_000);
470 // No h > 1 style suppression here: 1000 -> "یەک هەزار", not "هەزار".
471 let mut out = int_to_word_small(t);
472 out.push(' ');
473 out.push_str(THOUSAND);
474 if r != 0 {
475 out.push_str(JOINT);
476 out.push_str(&int_to_word_small(r));
477 }
478 return out;
479 }
480 // number < 1_000_000_000, guaranteed by the caller.
481 // m, r = divmod(number, 1000000)
482 let (m, r) = (number / 1_000_000, number % 1_000_000);
483 let mut out = int_to_word_small(m);
484 out.push(' ');
485 out.push_str(MILLION);
486 if r != 0 {
487 out.push_str(JOINT);
488 out.push_str(&int_to_word_small(r));
489 }
490 out
491}
492
493/// Python's `int(s)` for a whole token, as `to_cardinal` calls it on `n` and on
494/// `left`.
495///
496/// The `ValueError` message quotes the entire offending string, matching
497/// CPython — that is why `1e+16` reports `'1e+16'` (bug 10).
498///
499/// `BigInt::from_str` is stricter than `int()` in three ways that no repr can
500/// reach: `int()` also accepts surrounding whitespace, an underscore digit
501/// separator (`int("1_0") == 10`), and any Unicode `Nd` codepoint. None of
502/// those occur in a fragment of `str(float)` or `str(Decimal)`, and both agree
503/// on a leading `+`/`-` and on rejecting `""` and `"e"`.
504fn py_int(s: &str) -> Result<BigInt> {
505 BigInt::from_str(s)
506 .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
507}
508
509/// Python's `int(ch)` for a *single* character, as the fraction loop calls it.
510///
511/// The message quotes the one offending character, so `1.5e+16` reports `'e'`
512/// rather than `'5e+16'` (bug 10). `char::to_digit(10)` is ASCII-only where
513/// CPython's `int()` accepts `'٥'`; unreachable, since no repr emits a
514/// non-ASCII digit.
515fn py_int_digit(ch: char) -> Result<usize> {
516 ch.to_digit(10)
517 .map(|d| d as usize)
518 .ok_or_else(|| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch)))
519}
520
521/// The shortest round-trip decimal digits of `a` (finite, non-negative), plus
522/// CPython's `decpt`: the value is `0.<digits> * 10^decpt`.
523///
524/// # Why `{:e}` alone is not enough
525///
526/// Rust's `{:e}` and CPython's `repr` are both "the shortest string that reads
527/// back as the same double", and they agree on the digit *count* always and on
528/// the digits themselves almost always. They part company on an **exact tie**,
529/// where two equally-short decimals are equidistant from the true value and
530/// both round-trip: CPython's `_Py_dg_dtoa` breaks the tie to **even**, Rust's
531/// `flt2dec` shortest breaks it **away from zero**. The double whose exact
532/// value is `670352580196876.25` sits precisely between `...876.2` and
533/// `...876.3`, so `repr` says `.2` and `{:e}` says `.3`.
534///
535/// This is the same banker's-rounding trap `floatpath.rs` documents for
536/// `round()`, relocated into the formatter — and it matters here for the same
537/// reason: CKB's whole float path *is* the repr string, so a wrong last digit
538/// is a wrong word.
539///
540/// # The fix
541///
542/// Rust's *fixed-precision* `{:.*e}` is correctly rounded half-to-even, so
543/// re-emitting the same number of significant digits through it applies
544/// CPython's tie rule. It is kept only if it still round-trips, so a
545/// hypothetical asymmetric rounding interval (near a power of two, where the
546/// nearer decimal can sit outside the interval) falls back to the shortest
547/// digits rather than silently producing a string that reads back as a
548/// different double.
549fn shortest_repr_digits(a: f64) -> (String, i32) {
550 let split = |s: &str| -> (String, i32) {
551 let (mant, exp) = s.split_once('e').expect("{:e} always emits an 'e'");
552 (
553 mant.chars().filter(|c| *c != '.').collect(),
554 // `{:e}` normalises to exactly one digit before the point, so the
555 // decimal point sits one place left of where CPython counts it.
556 exp.parse::<i32>().expect("{:e} exponent is an integer") + 1,
557 )
558 };
559
560 let shortest = format!("{:e}", a);
561 let (digits, decpt) = split(&shortest);
562
563 let ties_even = format!("{:.*e}", digits.len() - 1, a);
564 if ties_even.parse::<f64>() == Ok(a) {
565 return split(&ties_even);
566 }
567 (digits, decpt)
568}
569
570/// CPython's `str(float)` / `repr(float)`, which is where CKB's float path
571/// begins and ends.
572///
573/// `PyOS_double_to_string(v, 'r', 0, Py_DTSF_ADD_DOT_0)` in `pystrtod.c`:
574/// take the shortest round-trip digits, then
575///
576/// * switch to exponent form when `decpt <= -4 || decpt > 16` — hence
577/// `str(1e15) == "1000000000000000.0"` but `str(1e16) == "1e+16"`, and
578/// `str(0.0001) == "0.0001"` but `str(1e-05) == "1e-05"`. (CPython moved this
579/// threshold down from 1e17 so that `repr(2e16+8)` stops claiming
580/// `20000000000000010.0` for a value that is really `...008.0`.) Everything
581/// on the far side of that boundary is bug 10's `ValueError`.
582/// * format that exponent `%+.02d` — signed, at least two digits, so `1e+16`
583/// and `1e-05` but `5e-324`.
584/// * otherwise print positionally and append `.0` if nothing follows the point
585/// (`Py_DTSF_ADD_DOT_0`), which is the whole reason `1.0` is `"یەک خاڵ سفر"`
586/// and not `"یەک"`.
587fn py_str_f64(v: f64) -> String {
588 // Unreachable from the shim, which computes `precision` as
589 // `abs(Decimal(str(value)).as_tuple().exponent)` and would raise on the
590 // 'F'/'n' exponent of a non-finite Decimal long before Rust is called.
591 // Handled anyway so this is a faithful `str()` rather than a
592 // faithful-in-context one; CKB goes on to raise `ValueError` on
593 // `int("inf")`, exactly as Python does.
594 if v.is_nan() {
595 return "nan".to_string();
596 }
597 if v.is_infinite() {
598 return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
599 }
600
601 // The sign is taken from the sign *bit*, not from `v < 0.0`, so that
602 // `str(-0.0)` is "-0.0" and CKB's `startswith("-")` fires: -0.0 renders
603 // "نێگەتیڤ سفر خاڵ سفر".
604 let sign = if v.is_sign_negative() { "-" } else { "" };
605 let (digits, decpt) = shortest_repr_digits(v.abs());
606 let ndig = digits.len() as i32;
607
608 if decpt <= -4 || decpt > 16 {
609 let mantissa = if ndig > 1 {
610 format!("{}.{}", &digits[..1], &digits[1..])
611 } else {
612 // No ADD_DOT_0 in exponent form: `str(1e16)` is "1e+16", not
613 // "1.0e+16" — which is exactly why it has no "." for CKB to split
614 // on, and so reports the whole token rather than 'e' (bug 10).
615 digits.clone()
616 };
617 let exp = decpt - 1;
618 let (esign, eabs) = if exp < 0 {
619 ("-", -(exp as i64))
620 } else {
621 ("+", exp as i64)
622 };
623 return format!("{}{}e{}{:0>2}", sign, mantissa, esign, eabs);
624 }
625 if decpt <= 0 {
626 // "0." + leading zeros + digits: 0.5 -> "0.5", 1e-4 -> "0.0001".
627 format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
628 } else if decpt >= ndig {
629 // Nothing after the point, so Py_DTSF_ADD_DOT_0 supplies ".0".
630 format!("{}{}{}.0", sign, digits, "0".repeat((decpt - ndig) as usize))
631 } else {
632 format!(
633 "{}{}.{}",
634 sign,
635 &digits[..decpt as usize],
636 &digits[decpt as usize..]
637 )
638 }
639}
640
641/// CPython's `Decimal.__str__` (the spec's `to-scientific-string`), ported from
642/// `_pydecimal.Decimal.__str__`.
643///
644/// ```python
645/// leftdigits = self._exp + len(self._int)
646/// if self._exp <= 0 and leftdigits > -6:
647/// dotplace = leftdigits # positional
648/// else:
649/// dotplace = 1 # scientific
650/// ```
651///
652/// # Why this cannot just be `BigDecimal`'s `Display`
653///
654/// The two disagree in three places, each of which would change CKB's output or
655/// its exception:
656///
657/// | value | `Decimal.__str__` | `BigDecimal` `Display` |
658/// |---|---|---|
659/// | `1E+2` | `1E+2` (CKB: `ValueError`) | `100` (CKB: "سەد") |
660/// | `0.0` | `0.0` (CKB: "سفر خاڵ سفر") | `0` (CKB: "سفر") |
661/// | `1E+16` | `1E+16` | `1e+16` — lowercase |
662///
663/// So the digits and exponent are read off `as_bigint_and_exponent()` and
664/// reassembled by Python's rule instead. That pairing is exact:
665/// `BigDecimal::from_str` keeps the written scale rather than normalising
666/// (`"1.10"` stays coefficient 110 / scale 2, which is what makes the trailing
667/// "سفر" appear), and `(coefficient, -scale)` is precisely Python's
668/// `(_int, _exp)` — including for values Python itself cannot tell apart, since
669/// `Decimal("1E-7")` and `Decimal("0.0000001")` *are* the same object and both
670/// stringify "1E-7".
671///
672/// One known divergence: negative zero. See [`LangCkb::to_cardinal_float`].
673fn py_str_decimal(value: &BigDecimal) -> String {
674 // BigDecimal stores value = coefficient * 10^-scale, so Python's `_exp`
675 // (which counts the other way) is the negated scale.
676 let (coefficient, scale) = value.as_bigint_and_exponent();
677 let exp = -scale;
678 let sign = if coefficient.is_negative() { "-" } else { "" };
679 let int_digits = coefficient.abs().to_string();
680 let ndig = int_digits.len() as i64;
681
682 let leftdigits = exp + ndig;
683 let dotplace = if exp <= 0 && leftdigits > -6 {
684 leftdigits
685 } else {
686 1
687 };
688
689 let (intpart, fracpart) = if dotplace <= 0 {
690 (
691 "0".to_string(),
692 format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
693 )
694 } else if dotplace >= ndig {
695 (
696 format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
697 String::new(),
698 )
699 } else {
700 (
701 int_digits[..dotplace as usize].to_string(),
702 format!(".{}", &int_digits[dotplace as usize..]),
703 )
704 };
705
706 // `['e', 'E'][context.capitals]`, and the default context has capitals=1,
707 // so the exponent marker is an uppercase 'E' — unlike `repr(float)`'s.
708 let exponent = if leftdigits == dotplace {
709 String::new()
710 } else {
711 format!("E{:+}", leftdigits - dotplace)
712 };
713
714 format!("{}{}{}{}", sign, intpart, fracpart, exponent)
715}
716
717impl Lang for LangCkb {
718
719 fn cardinal_float_entry(
720 &self,
721 value: &crate::floatpath::FloatValue,
722 precision_override: Option<u32>,
723 ) -> crate::base::Result<String> {
724 // Python's to_cardinal routes every float/Decimal through this
725 // language's own decimal grammar — 5.0 keeps its ".0" tail
726 // ("comma nulla"), unlike Base's whole-value integer route.
727 self.to_cardinal_float(value, precision_override)
728 }
729
730 /// `to_ordinal(float/Decimal)`: Python's `to_ordinal` is
731 /// `self.to_cardinal(number) + "em"` with no verify_ordinal guard, so a
732 /// float keeps its spelled-out ".0" tail and the suffix lands on the last
733 /// word: `to_ordinal(5.0)` == "پێنج خاڵ سفرەم", `to_ordinal(-1.5)` ==
734 /// "نێگەتیڤ یەک خاڵ پێنجەم". Exponent-form reprs raise the cardinal
735 /// path's ValueError.
736 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
737 Ok(format!(
738 "{}{}",
739 self.to_cardinal_float(value, None)?,
740 ORDINAL_SUFFIX
741 ))
742 }
743
744 /// `to_ordinal_num(float/Decimal)`: `str(number) + ەم` — the repr
745 /// verbatim, sign and trailing zeros included: `-0.0` → "-0.0ەم",
746 /// `Decimal("5.00")` → "5.00ەم", `1e16` → "1e+16ەم".
747 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
748 Ok(format!("{}{}", repr_str, ORDINAL_SUFFIX))
749 }
750
751 /// `converter.str_to_number` — base `Decimal(value)` semantics, except
752 /// that an Infinity parse is surfaced as the ValueError CKB's own
753 /// `to_cardinal` raises one step later: `str(Decimal("Infinity"))` has no
754 /// "." and `int("Infinity")` chokes on the literal. The dispatcher's
755 /// default maps `ParsedNumber::Inf` to base's OverflowError, which CKB
756 /// can never raise. NaN keeps the default routing (ValueError either way).
757 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
758 match python_decimal_parse(s)? {
759 ParsedNumber::Inf { .. } => Err(N2WError::Value(
760 "invalid literal for int() with base 10: 'Infinity'".into(),
761 )),
762 other => Ok(other),
763 }
764 }
765
766 /// This language's own `to_currency(currency=...)` default,
767 /// read from the live Python signature. Only 44 of 156 use EUR.
768 fn default_currency(&self) -> &str {
769 "IQD"
770 }
771
772 /// This language's own `to_currency(separator=...)` default,
773 /// read from the live Python signature. Base's is ",", but only
774 /// 36 of 149 languages actually use it — most default to " " or a
775 /// conjunction, so inheriting Base's comma silently corrupts them.
776 fn default_separator(&self) -> &str {
777 " "
778 }
779
780 fn negword(&self) -> &str {
781 NEGWORD
782 }
783
784 fn pointword(&self) -> &str {
785 "خاڵ"
786 }
787
788 fn exclude_title(&self) -> &[String] {
789 &self.exclude_title
790 }
791
792 /// Port of `Num2Word_CKB.to_cardinal`, integer path only.
793 ///
794 /// Python stringifies the input (`n = str(number).strip()`) and branches on
795 /// the text:
796 /// * `n.startswith("-")` → `(self.negword + self.to_cardinal(n[1:])).strip()`
797 /// * `"." in n` → the float path (out of scope; `str(int)` never has a dot)
798 /// * otherwise → `self._int_to_word(int(n))`
799 ///
800 /// The negative branch recurses on the *string* `n[1:]`, which for an
801 /// integer is exactly `abs(value)` rendered in decimal — that recursive call
802 /// can only fall through to `_int_to_word`, so it collapses to a direct
803 /// call here. `int("-")` (`ValueError`) is unreachable from a `BigInt`,
804 /// since `str` of an integer is never a bare `"-"`.
805 ///
806 /// The trailing `.strip()` is a no-op in practice — `_int_to_word` never
807 /// returns an empty or padded string for a non-negative input — but is kept
808 /// for fidelity.
809 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
810 if value.is_negative() {
811 let inner = self.int_to_word(&value.abs());
812 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
813 }
814 Ok(self.int_to_word(value))
815 }
816
817 /// Port of `Num2Word_CKB.to_cardinal` for `float` / `Decimal` input.
818 ///
819 /// The same Python method as [`Lang::to_cardinal`] — CKB fuses both entry
820 /// points into one `to_cardinal` and tells them apart by looking for a `"."`
821 /// in `str(number)`. So this is not an override of
822 /// `Num2Word_Base.to_cardinal_float`: **that method is never reached for
823 /// CKB**, and neither is `base.float2tuple`. All the work is in
824 /// [`LangCkb::cardinal_from_repr`]; this hook only has to rebuild the `n =
825 /// str(number)` that the shim took apart.
826 ///
827 /// # Why the two arms cannot share a formatter
828 ///
829 /// `str` of a `Decimal` keeps every written digit and `str` of a `float`
830 /// keeps the shortest round-trip ones, so `Decimal("1.10")` ends in
831 /// `"سفر"` where the float `1.1` cannot — issue #603's split is visible in
832 /// the *output* here, not just in the last bits. Hence [`py_str_decimal`]
833 /// for one and [`py_str_f64`] for the other.
834 ///
835 /// # `precision_override` is deliberately ignored
836 ///
837 /// `num2words(2.675, lang="ckb", precision=1)` returns the full
838 /// `"دوو خاڵ شەش حەوت پێنج"`, not a one-digit fraction. `__init__.py` pops
839 /// `precision=`, and since `hasattr(converter, "precision")` is True
840 /// (`Num2Word_Base.__init__` sets it) it does assign `converter.precision`
841 /// and restore it afterwards — but `Num2Word_CKB.to_cardinal` never reads
842 /// the attribute. Only `base.float2tuple` does, and CKB never calls it. So
843 /// the kwarg is a no-op for this language rather than unimplemented, and
844 /// dropping it is the faithful behaviour. Verified live at precision 1 and
845 /// 6: both give the untruncated three-digit fraction.
846 ///
847 /// # Known divergence: negative zero on the `Decimal` arm
848 ///
849 /// `str(Decimal("-0.0"))` is `"-0.0"`, so Python renders
850 /// `"نێگەتیڤ سفر خاڵ سفر"`. `BigDecimal` has no signed zero — the shim's
851 /// `BigDecimal::from_str("-0.0")` yields coefficient `0`, whose sign is
852 /// gone by the time this sees it — so the Rust output is `"سفر خاڵ سفر"`.
853 /// Not fixable from this file: it is lost in the crossing, before
854 /// `to_cardinal_float` is entered. The `float` arm is unaffected (it reads
855 /// the IEEE sign bit, and `-0.0` survives as an f64). Flagged in `concerns`.
856 fn to_cardinal_float(
857 &self,
858 value: &FloatValue,
859 precision_override: Option<u32>,
860 ) -> Result<String> {
861 let _ = precision_override;
862 // n = str(number)
863 let n = match value {
864 FloatValue::Float { value, .. } => py_str_f64(*value),
865 FloatValue::Decimal { value, .. } => py_str_decimal(value),
866 };
867 self.cardinal_from_repr(&n)
868 }
869
870 /// Port of `Num2Word_CKB.to_ordinal`: `self.to_cardinal(number) + ەم`.
871 ///
872 /// No joint, no stem change, no negative guard — the suffix simply lands on
873 /// the last word (bug 4).
874 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
875 Ok(self.to_cardinal(value)? + ORDINAL_SUFFIX)
876 }
877
878 /// Port of `Num2Word_CKB.to_ordinal_num`: `str(number) + ەم`.
879 ///
880 /// Overrides the base's bare `str(value)`. The sign survives verbatim:
881 /// `to_ordinal_num(-1)` == `"-1ەم"`.
882 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
883 Ok(value.to_string() + ORDINAL_SUFFIX)
884 }
885
886 /// Port of `Num2Word_CKB.to_year`: `self.to_cardinal(val)`.
887 ///
888 /// The `longval` parameter is accepted and ignored by Python — there is no
889 /// century-pair reading ("nineteen eighty-four"); years are read as plain
890 /// cardinals. Corpus: 1984 -> "یەک هەزار و نۆ سەد و هەشتا و چوار".
891 fn to_year(&self, value: &BigInt) -> Result<String> {
892 self.to_cardinal(value)
893 }
894
895 // ---- currency ----------------------------------------------------
896
897 /// `self.__class__.__name__`. Only `to_cheque` reads it: it is the sole
898 /// CKB path that can raise `NotImplementedError`, since `to_currency`
899 /// falls back to IQD instead of raising (bug 7).
900 fn lang_name(&self) -> &str {
901 LANG_NAME
902 }
903
904 /// `Num2Word_CKB.CURRENCY_FORMS[code]`, strict — **no IQD fallback here**.
905 ///
906 /// The fallback belongs to `to_currency` alone. `to_cheque` (inherited from
907 /// `Num2Word_Base`) does a bare `self.CURRENCY_FORMS[currency]` and lets the
908 /// `KeyError` become `NotImplementedError`, so this hook must miss for an
909 /// unknown code. Corpus: `cheque:GBP` → NotImplementedError while
910 /// `currency:GBP` → `"... دینار ..."`.
911 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
912 self.currency_forms.get(code)
913 }
914
915 // `currency_adjective` and `currency_precision` stay at their trait
916 // defaults: `Num2Word_CKB` defines neither `CURRENCY_ADJECTIVES` nor
917 // `CURRENCY_PRECISION`, and the `Num2Word_Base` class dicts it inherits are
918 // both still empty at runtime (verified live). Note the asymmetry with
919 // `Num2Word_EN`, which *mutates* the shared `Num2Word_EUR.CURRENCY_FORMS`
920 // in place — CKB owns its forms dict outright, so nothing leaks in.
921 // `default_to_cheque` therefore sees divisor 100 for every code, which is
922 // where the corpus's `56/100` comes from.
923
924 /// Port of `Num2Word_CKB.pluralize`.
925 ///
926 /// ```python
927 /// if not forms:
928 /// return ""
929 /// return forms[0] if n == 1 else forms[-1]
930 /// ```
931 ///
932 /// Overrides the base's `raise NotImplementedError`. Currently unreachable:
933 /// CKB's `to_currency` inlines the equivalent choice itself, and
934 /// `default_to_cheque` unconditionally takes the plural form. Implemented
935 /// anyway because `Num2Word_CKB` defines it, so the surface must match.
936 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
937 match forms.last() {
938 None => Ok(String::new()),
939 Some(plural) => Ok(if n.is_one() {
940 forms[0].clone()
941 } else {
942 plural.clone()
943 }),
944 }
945 }
946
947 /// Port of `Num2Word_CKB.to_currency` — a full override of the base's.
948 ///
949 /// ```python
950 /// def to_currency(self, val, currency="IQD", cents=True, separator=" ", adjective=False):
951 /// is_negative = val < 0
952 /// val = abs(val)
953 /// parts = str(val).split(".")
954 /// left = int(parts[0]) if parts[0] else 0
955 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
956 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
957 /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
958 /// if cents and right:
959 /// result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
960 /// if is_negative:
961 /// result = self.negword + result
962 /// return result.strip()
963 /// ```
964 ///
965 /// Nothing in it can raise, so this always returns `Ok`. It shares no code
966 /// with `Num2Word_Base.to_currency`: no `parse_currency_parts`, no
967 /// `pluralize`, no `_money_verbose`/`_cents_verbose`/`_cents_terse`, no
968 /// `CURRENCY_PRECISION`, no `CURRENCY_ADJECTIVES`.
969 ///
970 /// `adjective` is accepted and **ignored** — the parameter exists in the
971 /// signature but the body never reads it, so `adjective=True` is a no-op.
972 /// (`CURRENCY_ADJECTIVES` is empty for CKB regardless, so the base class
973 /// would have been a no-op here too.)
974 ///
975 /// # Bugs preserved
976 ///
977 /// 6. **`negword` is concatenated raw, not `.strip()`-ed then spaced.**
978 /// `Num2Word_Base.to_currency` builds `"%s " % self.negword.strip()`;
979 /// CKB writes `self.negword + result`. Both land on `"نێگەتیڤ "` here
980 /// only because `setup` gave `negword` a trailing space. So this uses
981 /// [`NEGWORD`] verbatim — *not* `negword().trim()` + `" "` the way
982 /// `currency::default_to_currency` does.
983 /// 7. **An unknown currency code silently renders as Iraqi dinars.**
984 /// `.get(currency, list(self.CURRENCY_FORMS.values())[0])` falls back to
985 /// the first entry instead of raising. So `currency="GBP"` — and even
986 /// `currency="ZZZ"` — yields `"... دینار ... فلس"` rather than the
987 /// `NotImplementedError` every other mode raises. Corpus confirms it for
988 /// GBP, JPY, KWD, BHD, INR, CNY and CHF: all seven render as dînar/fils.
989 /// 8. **`CURRENCY_PRECISION` is ignored; every code is 2-decimal.** The
990 /// hardcoded `[:2]` means KWD/BHD get 2 fractional digits rather than 3
991 /// (`12.34` → 34 fils, not 340) and JPY gets cents rather than none
992 /// (`12.34` → `"دوانزە دینار سی و چوار فلس"`, where the base class
993 /// would have rounded to a whole 12). Corpus confirms both.
994 /// 9. **The unit form is indexed `cr1[1]`, not `cr1[-1]`.** Identical for
995 /// CKB's four 2-tuples, but it would `IndexError` on a 1-tuple form.
996 /// Mirrored as a direct index rather than `.last()`.
997 fn to_currency(
998 &self,
999 val: &CurrencyValue,
1000 currency: &str,
1001 cents: bool,
1002 separator: Option<&str>,
1003 _adjective: bool,
1004 ) -> Result<String> {
1005 // Trait now hands us None when the caller omitted separator=;
1006 // resolve it to this language's own default before the ported body.
1007 let separator = separator.unwrap_or(self.default_separator());
1008 let is_negative = val.is_negative();
1009 let (left, right) = self.currency_parts(val);
1010
1011 let forms = self
1012 .currency_forms
1013 .get(currency)
1014 .unwrap_or(&self.fallback_forms); // bug 7
1015 let (cr1, cr2) = (&forms.unit, &forms.subunit);
1016
1017 // result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
1018 let mut result = self.int_to_word(&left);
1019 result.push(' ');
1020 result.push_str(if left.is_one() { &cr1[0] } else { &cr1[1] }); // bug 9
1021
1022 // if cents and right:
1023 if cents && !right.is_zero() {
1024 result.push_str(separator);
1025 result.push_str(&self.int_to_word(&right));
1026 result.push(' ');
1027 result.push_str(if right.is_one() { &cr2[0] } else { &cr2[1] });
1028 }
1029
1030 // if is_negative: result = self.negword + result
1031 if is_negative {
1032 result = format!("{}{}", NEGWORD, result); // bug 6
1033 }
1034
1035 // return result.strip() — a no-op in practice (nothing here can leave
1036 // leading or trailing whitespace), kept for fidelity.
1037 Ok(result.trim().to_string())
1038 }
1039
1040 // `to_cheque` is NOT overridden: `Num2Word_CKB` inherits
1041 // `Num2Word_Base.to_cheque` verbatim (verified live —
1042 // `c.to_cheque.__func__.__qualname__ == "Num2Word_Base.to_cheque"`), which
1043 // `currency::default_to_cheque` already ports. It reaches back through
1044 // `currency_forms` (strict, so unknown codes raise), `currency_precision`
1045 // (100) and `money_verbose` → the default → CKB's `to_cardinal`.
1046 //
1047 // `cardinal_from_decimal` likewise stays at its default, and is dead code
1048 // for CKB. Its only caller is `currency::default_to_currency`'s
1049 // fractional-cents branch, and CKB overrides `to_currency` outright (its
1050 // `[:2]` truncates rather than carrying a fraction of a cent), so that
1051 // branch is unreachable. `default_to_cheque` does not call it either.
1052 //
1053 // This matters more than it looks: the default routes to
1054 // `floatpath::cardinal_from_bigdecimal`, which calls
1055 // `default_to_cardinal_float` *free-function-style* rather than through
1056 // `self.to_cardinal_float`, so it would bypass the override above and go
1057 // through `float2tuple` — a path CKB's Python never takes. It is left alone
1058 // because it is unreachable, not because it would agree.
1059 //
1060 // `pointword()` is likewise never read by CKB's own code: `POINTWORD` is
1061 // spliced in directly by `cardinal_from_repr`, mirroring Python's
1062 // `self.pointword`. The trait method is kept in sync for the base-class
1063 // paths that no longer reach it.
1064}