Skip to main content

num2words2_core/
lang_km.rs

1//! Port of `lang_KM.py` (Khmer).
2//!
3//! Registry check: `__init__.py` maps `"km": lang_KM.Num2Word_KM()`, so this
4//! file ports `Num2Word_KM` — the only class the module defines.
5//!
6//! Shape: **self-contained**. `Num2Word_KM` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
8//! `Num2Word_Base.__init__` never builds `self.cards` and never assigns
9//! `self.MAXVAL`. `to_cardinal` is overridden outright and delegates to
10//! `_int_to_word`, a hand-written cascade of magnitude branches. Consequently
11//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
12//! **no overflow check at all** — see bug 1 below for what happens instead.
13//!
14//! `setup()` sets only `negword = "ដក "` and `pointword = "ចំណុច"` plus the
15//! `ones`/`tens` tables. `pointword` is unreachable for *integer* input (the
16//! `"." in n` branch of `to_cardinal` never fires for an int), but it *is*
17//! reached for float/Decimal input — see the float path below.
18//!
19//! # The float/Decimal path
20//!
21//! Unlike almost every other language, `Num2Word_KM` does **not** override
22//! `to_cardinal_float` and does **not** use `base.float2tuple`. It overrides
23//! `to_cardinal` outright and handles non-integers *inline* by string surgery
24//! on `str(number)`:
25//!
26//! ```python
27//! n = str(number).strip()
28//! if n.startswith("-"): n = n[1:]; ret = self.negword
29//! else: ret = ""
30//! if "." in n:
31//!     left, right = n.split(".", 1)
32//!     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
33//!     for digit in right: ret += self._int_to_word(int(digit)) + " "
34//!     return ret.strip()
35//! else:
36//!     return (ret + self._int_to_word(int(n))).strip()
37//! ```
38//!
39//! Because this reads `str(number)` digit by digit, it is **exact** where the
40//! shared `float2tuple` path is lossy: `float2tuple` computes
41//! `(value - pre) * 10**precision` in binary f64 and loses the last fractional
42//! digit past ~12 places, whereas `str(float)` is the shortest round-trip
43//! `repr`. The default trait `to_cardinal_float` therefore diverges from KM on
44//! `-0.0` (KM prints the negword because `str(-0.0)` begins `"-"`) and on
45//! high-precision floats (last-digit drift). Hence `to_cardinal_float` is
46//! overridden here to reproduce the string-surgery cascade.
47//!
48//! Consequences reproduced verbatim:
49//!
50//! * **`precision=` is ignored.** KM's `to_cardinal` never reads
51//!   `self.precision`; it reads `str(number)`. So the `precision_override`
52//!   kwarg cannot change the output, and it is dropped here.
53//! * **`-0.0` renders as negative.** `str(-0.0) == "-0.0"` starts with `"-"`,
54//!   so the negword is prefixed. `value.is_sign_negative()` catches this f64
55//!   negative zero, which `value < 0.0` would miss.
56//! * **The million-space / digit-fallback bugs of `_int_to_word` carry over**
57//!   because the whole part is rendered through the very same function.
58//!
59//! ## Float routing: every float/Decimal goes through the string surgery
60//!
61//! Because `to_cardinal` branches on `"." in str(number)` — not on
62//! `int(value) == value` — a *whole* float still reads its trailing zero:
63//! `to_cardinal(5.0)` == `"ប្រាំ ចំណុច សូន្យ"`, and `Decimal("5.00")` reads two
64//! zero digits. The base trait's `cardinal_float_entry` (whole → int path)
65//! is therefore overridden to send **all** float/Decimal input through the
66//! cascade. `to_ordinal`/`to_year` are plain prefixings of `to_cardinal`, so
67//! their float entries prefix the same string-surgery output, and
68//! `to_ordinal_num`'s float entry prefixes the binding-provided `str(value)`.
69//!
70//! ## Exponential reprs are reconstructed, and raise like Python
71//!
72//! `str(number)` is rebuilt here: `str(Decimal)` via
73//! [`crate::strnum::python_decimal_str`] (exact spec port, so
74//! `Decimal("1E+2")` yields `"1E+2"`), and `str(float)` via [`py_float_str`],
75//! which mirrors CPython's shortest-repr formatting *including* the switch to
76//! exponent form at `|v| >= 1e16` or `0 < |v| < 1e-4`. Feeding those strings
77//! through the very same `int()` surgery reproduces Python's failures
78//! verbatim: `int("1e+16")` / `int("1E+2")` raise `ValueError` (no `"."` in
79//! the repr), and a mantissa-dotted form like `1.23e+16` dies on `int("e")`
80//! inside the digit loop. Corpus rows pin `1e+16`, `1e+20`, `Decimal("1E+2")`
81//! and `Decimal("1E+20")` on ValueError for cardinal/ordinal/year, while
82//! `ordinal_num` echoes them (`"ទី1e+16"`).
83//!
84//! `Decimal("-0.0")` reaches this module as `FloatValue::Float { -0.0 }` (the
85//! binding converts it: `BigDecimal` cannot carry a signed zero), and the
86//! f64 sign bit keeps the negword — `"ដក សូន្យ ចំណុច សូន្យ"`, as pinned.
87//!
88//! ## String input: Infinity/NaN raise ValueError, not OverflowError
89//!
90//! `num2words("Infinity", lang="km")` parses to `Decimal("Infinity")` and
91//! lands in this `to_cardinal`, where `str()` gives `"Infinity"` — no `"."` —
92//! and `int("Infinity")` raises **ValueError**. The binding would otherwise
93//! map `ParsedNumber::Inf` to the base path's OverflowError before any KM
94//! code runs, so `str_to_number` intercepts Inf (and NaN, whose binding
95//! message differs from `int("NaN")`'s) and raises the ValueError here.
96//! Known gap (unpinned): Python's `to_ordinal_num(Decimal("Infinity"))`
97//! would echo `"ទីInfinity"`; the entry-level interception raises instead.
98//! The strings corpus pins Infinity/NaN under `to=cardinal` only.
99//!
100//! Every in-scope method is overridden by `Num2Word_KM`; nothing is inherited
101//! from `Num2Word_Base` for `to_cardinal`/`to_ordinal`/`to_ordinal_num`/
102//! `to_year`. In particular `verify_ordinal` is **never called**, so negative
103//! ordinals do not raise — they render (bug 3).
104//!
105//! # Faithfully reproduced Python oddities
106//!
107//! This is a port, not a rewrite. All of the following look wrong but are
108//! exactly what Python emits, verified against the frozen corpus:
109//!
110//! 1. **Numbers >= 10^9 are not converted at all.** The final `else` of
111//!    `_int_to_word` is `return str(number)`, a "fallback for very large
112//!    numbers". So `to_cardinal(10**9)` == `"1000000000"` and
113//!    `to_cardinal(10**21)` == `"1000000000000000000000"` — bare ASCII
114//!    digits, no Khmer, and no `OverflowError`. Corpus rows confirm this for
115//!    10^9, 10^12, 10^15 and 10^21. This is why the value must stay a
116//!    `BigInt`: the fallback is reached by arbitrarily large input and the
117//!    digits are echoed verbatim.
118//! 2. **Inconsistent spacing around the "million" word.** Every other
119//!    magnitude word is glued to its multiplier with no space
120//!    (`"មួយ" + "រយ"` -> `"មួយរយ"`), but the million branch is
121//!    `self._int_to_word(millions_val) + " លាន"` — with a leading space. So
122//!    `to_cardinal(10**6)` == `"មួយ លាន"` (spaced) while
123//!    `to_cardinal(1000)` == `"មួយពាន់"` (unspaced). Preserved verbatim.
124//! 3. **`to_ordinal` prefixes the negword-bearing cardinal**, giving
125//!    `to_ordinal(-1)` == `"ទីដក មួយ"` — the ordinal marker "ទី" lands in
126//!    front of the minus word rather than the number. Likewise
127//!    `to_ordinal(10**9)` == `"ទី1000000000"`, i.e. identical to
128//!    `to_ordinal_num(10**9)`, because bug 1 makes the cardinal a digit
129//!    string.
130//! 4. **`to_year` ignores its `longval` argument entirely** and is just
131//!    `"ឆ្នាំ " + to_cardinal(val)`. There is no BC/AD handling and no
132//!    two-chunk year reading: `to_year(1984)` reads as the plain cardinal.
133//!    Negative years keep the cardinal's negword, so `to_year(-500)` ==
134//!    `"ឆ្នាំ ដក ប្រាំរយ"`.
135//!
136//! # Currency
137//!
138//! `Num2Word_KM` overrides `to_currency` outright but **not** `to_cheque`,
139//! which splits the two surfaces apart:
140//!
141//! * `to_currency` looks its code up with `.get(currency, CURRENCY_FORMS["KHR"])`,
142//!   so an unimplemented code silently renders in riel instead of raising.
143//! * `to_cheque` is `Num2Word_Base`'s, which subscripts `CURRENCY_FORMS[currency]`
144//!   and turns the `KeyError` into `NotImplementedError`.
145//!
146//! Hence `currency:GBP` -> "សូន្យ រៀល" but `cheque:GBP` -> NotImplementedError,
147//! both confirmed by the corpus. `to_cheque` needs no override at all: its
148//! `_money_verbose` is `self.to_cardinal`, matching the trait default, and
149//! `.upper()` is a no-op on Khmer (the script is caseless), so
150//! `default_to_cheque` already emits KM's bytes exactly.
151//!
152//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both empty (inherited
153//! unmodified from `Num2Word_Base`), so precision defaults to 100 — read only
154//! by the cheque path — and `adjective` can never prefix anything.
155//!
156//! # Known gap: floats whose `repr` is exponential
157//!
158//! KM's `to_currency` is alone in doing string surgery on `str(val)` rather
159//! than arithmetic, which makes it sensitive to a detail `CurrencyValue`
160//! cannot carry: a float's *textual* form. The shim parses `str(value)` into a
161//! `BigDecimal`, and that parse is lossy for exponential reprs — the original
162//! spelling is gone by the time this file sees it. Divergences, none of which
163//! the corpus exercises (its args are all plain decimals):
164//!
165//! | input | Python | here |
166//! |---|---|---|
167//! | `1e16` | ValueError (`int("1e+16")`) | ValueError — Display round-trips `"1e+16"`, so this one agrees by luck |
168//! | `1e-05` | ValueError (`int("1e-05")`) | `"សូន្យ រៀល"` — Display normalises to `"0.00001"` |
169//! | `1.2345678901234568e+17` | `"មួយ រៀល ម្ភៃបី សេន"` (left=1, right=23!) | the digit fallback, `"123456789012345680 រៀល"` |
170//!
171//! Closing this would require the shim to pass `str(value)` through verbatim
172//! rather than pre-parsed, which is outside this file. Flagged rather than
173//! papered over: reproducing Python's `repr(float)` here would be a second
174//! shortest-round-trip formatter and a permanent source of drift, exactly what
175//! `currency.rs`'s header warns against.
176//!
177//! # No cross-call mutable state
178//!
179//! `Num2Word_KM` stashes no flags between methods (no `_pending_ordinal`-style
180//! handshake as in `lang_ES`). `setup()` only populates constant tables, and
181//! `self.precision` is touched solely by float paths, which are out of scope.
182//! The stateless Rust translation is therefore exact. The currency forms table
183//! is built once in `new()` and only ever read.
184
185use crate::base::{Lang, N2WError, Result};
186use crate::currency::{CurrencyForms, CurrencyValue};
187use crate::floatpath::FloatValue;
188use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
189use num_bigint::BigInt;
190use num_traits::{Signed, ToPrimitive, Zero};
191use std::collections::HashMap;
192use std::str::FromStr;
193
194/// `self.negword`, set in `setup()`. Note the **trailing space** — it is part
195/// of the literal and is what separates it from the number, since KM's
196/// `to_cardinal` concatenates `ret + word` directly rather than joining.
197const NEGWORD: &str = "ដក ";
198
199/// `self.ones`, indices 0..=9.
200const ONES: [&str; 10] = [
201    "សូន្យ",      // 0
202    "មួយ",        // 1
203    "ពីរ",        // 2
204    "បី",         // 3
205    "បួន",        // 4
206    "ប្រាំ",       // 5
207    "ប្រាំមួយ",     // 6
208    "ប្រាំពីរ",     // 7
209    "ប្រាំបី",      // 8
210    "ប្រាំបួន",     // 9
211];
212
213/// `self.tens`, indices 0..=9. Index 0 is the empty string and is never read
214/// (the `< 100` branch only runs for `number >= 20`, so `tens_val >= 2`).
215const TENS: [&str; 10] = [
216    "",           // 0
217    "ដប់",        // 10
218    "ម្ភៃ",        // 20
219    "សាមសិប",     // 30
220    "សែសិប",      // 40
221    "ហាសិប",      // 50
222    "ហុកសិប",     // 60
223    "ចិតសិប",     // 70
224    "ប៉ែតសិប",     // 80
225    "កៅសិប",      // 90
226];
227
228/// The magnitude words, glued to their multiplier without a separator.
229const HUNDRED: &str = "រយ";
230const THOUSAND: &str = "ពាន់";
231const TEN_THOUSAND: &str = "ម៉ឺន";
232const HUNDRED_THOUSAND: &str = "សែន";
233/// The million word carries a *leading space* at its use site — see bug 2.
234const MILLION: &str = " លាន";
235
236/// `to_ordinal`'s prefix, "ទី".
237const ORDINAL_PREFIX: &str = "ទី";
238/// `to_year`'s prefix, "ឆ្នាំ " (trailing space included, as in Python).
239const YEAR_PREFIX: &str = "ឆ្នាំ ";
240
241/// The default currency and the fallback `to_currency` reaches for when the
242/// requested code is absent — see `to_currency`'s `.get(currency, ...)` note.
243const KHR: &str = "KHR";
244
245pub struct LangKm {
246    /// `Num2Word_KM.CURRENCY_FORMS`, built once in `new()`.
247    ///
248    /// `Num2Word_KM` derives straight from `Num2Word_Base` (MRO is
249    /// `[Num2Word_KM, Num2Word_Base, object]`), **not** from `Num2Word_EUR`,
250    /// so the class dict that `Num2Word_EN.__init__` mutates at import time
251    /// never reaches it. This table is exactly the three codes the source
252    /// declares — verified against the live interpreter, which reports only
253    /// `{EUR, KHR, USD}` and empty `CURRENCY_PRECISION`/`CURRENCY_ADJECTIVES`.
254    ///
255    /// Both unit forms and both subunit forms are identical in Khmer, but the
256    /// 2-tuple arity is kept as Python has it.
257    currency_forms: HashMap<&'static str, CurrencyForms>,
258}
259
260impl LangKm {
261    pub fn new() -> Self {
262        let mut currency_forms = HashMap::with_capacity(3);
263        currency_forms.insert(KHR, CurrencyForms::new(&["រៀល", "រៀល"], &["សេន", "សេន"]));
264        currency_forms.insert(
265            "USD",
266            CurrencyForms::new(&["ដុល្លារ", "ដុល្លារ"], &["សេន", "សេន"]),
267        );
268        currency_forms.insert("EUR", CurrencyForms::new(&["អឺរ៉ូ", "អឺរ៉ូ"], &["សេន", "សេន"]));
269        LangKm { currency_forms }
270    }
271
272    /// Python's `int(s)` for the digit runs `to_currency` slices out of
273    /// `str(val)`.
274    ///
275    /// Normally infallible — the operands come from `BigInt`/`BigDecimal`
276    /// Display and are plain digits. It can still fail, and *must*: Python
277    /// feeds `str(val)` to `int()` unguarded, so a float whose repr is
278    /// exponential (`str(1e16) == "1e+16"`) raises `ValueError`.
279    /// `BigDecimal`'s Display reproduces `"1e+16"` verbatim, so that row lands
280    /// on the same `ValueError` here. The message mirrors CPython's.
281    fn py_int(s: &str) -> Result<BigInt> {
282        BigInt::from_str(s).map_err(|_| {
283            N2WError::Value(format!(
284                "invalid literal for int() with base 10: '{}'",
285                s
286            ))
287        })
288    }
289
290    /// `Num2Word_KM._int_to_word`.
291    ///
292    /// Reached only with a non-negative value from `to_cardinal` (which strips
293    /// the sign off the *string* before calling `int()`), and recursion never
294    /// produces a negative. The `number < 0` arm below is therefore dead for
295    /// the four in-scope modes; it is kept because it is dead in Python too,
296    /// and reproducing the cascade verbatim keeps the branch bounds honest.
297    fn int_to_word(&self, number: &BigInt) -> String {
298        if number.is_zero() {
299            return ONES[0].to_string();
300        }
301
302        if number.is_negative() {
303            // Dead on every path. `to_cardinal` strips the sign off the string
304            // first, and `to_currency` — the other caller — does `val = abs(val)`
305            // before it ever slices digits out, so both operands it derives are
306            // non-negative. Mirrors `self.negword + self._int_to_word(abs(n))`,
307            // and is dead in Python for the same reasons.
308            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
309        }
310
311        // Each guard below bounds `number`, so the digit extracted for an
312        // ONES/TENS index is provably 0..=9 and the usize cast cannot fail.
313        // The final `else` needs no cast — it echoes the digits.
314        macro_rules! small {
315            ($v:expr) => {
316                $v.to_usize().expect("bounded by the enclosing guard")
317            };
318        }
319
320        let ten = BigInt::from(10);
321        let twenty = BigInt::from(20);
322        let hundred = BigInt::from(100);
323        let thousand = BigInt::from(1000);
324        let ten_thousand = BigInt::from(10_000);
325        let hundred_thousand = BigInt::from(100_000);
326        let million = BigInt::from(1_000_000);
327        let billion = BigInt::from(1_000_000_000);
328
329        if *number < ten {
330            return ONES[small!(number)].to_string();
331        }
332        if *number == ten {
333            return TENS[1].to_string();
334        }
335        if *number < twenty {
336            // `self.tens[1] + self.ones[number - 10]`; number-10 is 1..=9.
337            return format!("{}{}", TENS[1], ONES[small!(number - &ten)]);
338        }
339        if *number < hundred {
340            // Python's `//` and `%` are floor-based, but `number` is positive
341            // here so truncating and flooring agree; BigInt `/` and `%` are
342            // truncating, hence identical results.
343            let tens_val = number / &ten;
344            let ones_val = number % &ten;
345            if ones_val.is_zero() {
346                return TENS[small!(&tens_val)].to_string();
347            }
348            return format!("{}{}", TENS[small!(&tens_val)], ONES[small!(&ones_val)]);
349        }
350
351        // The four "glued" magnitudes share one shape: ONES[digit] + word,
352        // then `" " + recurse(remainder)` when the remainder is non-zero.
353        // Kept as an explicit cascade to mirror the Python branch order.
354        if *number < thousand {
355            return self.glued(number, &hundred, HUNDRED);
356        }
357        if *number < ten_thousand {
358            return self.glued(number, &thousand, THOUSAND);
359        }
360        if *number < hundred_thousand {
361            return self.glued(number, &ten_thousand, TEN_THOUSAND);
362        }
363        if *number < million {
364            return self.glued(number, &hundred_thousand, HUNDRED_THOUSAND);
365        }
366
367        if *number < billion {
368            // The one branch that *recurses* on the multiplier (it can be up
369            // to 999) and the one that puts a space before its magnitude word.
370            let millions_val = number / &million;
371            let remainder = number % &million;
372            let mut result = format!("{}{}", self.int_to_word(&millions_val), MILLION);
373            if !remainder.is_zero() {
374                result.push(' ');
375                result.push_str(&self.int_to_word(&remainder));
376            }
377            return result;
378        }
379
380        // "Fallback for very large numbers" — bug 1. `str(number)` on a Python
381        // int is plain decimal with no separators, which BigInt's Display
382        // matches exactly, at any width.
383        number.to_string()
384    }
385
386    /// The shared body of the hundred / thousand / ten-thousand /
387    /// hundred-thousand branches: `self.ones[n // unit] + word`, then
388    /// `" " + self._int_to_word(n % unit)` if that remainder is truthy.
389    ///
390    /// The caller's guard bounds `n // unit` to 1..=9, so the ONES index is
391    /// always valid — unlike the million branch, this never recurses on the
392    /// multiplier.
393    fn glued(&self, number: &BigInt, unit: &BigInt, word: &str) -> String {
394        let digit = number / unit;
395        let remainder = number % unit;
396        let mut result = format!(
397            "{}{}",
398            ONES[digit.to_usize().expect("bounded by the enclosing guard")],
399            word
400        );
401        if !remainder.is_zero() {
402            result.push(' ');
403            result.push_str(&self.int_to_word(&remainder));
404        }
405        result
406    }
407
408    /// The body of `Num2Word_KM.to_cardinal`, run on a rebuilt `str(number)`.
409    ///
410    /// ```python
411    /// n = str(number).strip()
412    /// if n.startswith("-"): n = n[1:]; ret = self.negword
413    /// else: ret = ""
414    /// if "." in n:
415    ///     left, right = n.split(".", 1)
416    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
417    ///     for digit in right: ret += self._int_to_word(int(digit)) + " "
418    ///     return ret.strip()
419    /// else:
420    ///     return (ret + self._int_to_word(int(n))).strip()
421    /// ```
422    ///
423    /// Working on the *string* keeps every Python failure mode intact: an
424    /// exponential repr with no dot dies in `int("1e+16")` (ValueError, whole
425    /// token in the message), one with a mantissa dot dies on the first
426    /// non-digit of the fractional walk (`int("e")`), and `str(inf)`/`"NaN"`
427    /// die in `int(...)` the same way. `split_once` is Python's
428    /// `split(".", 1)`; only the first dot separates.
429    fn cardinal_from_pystr(&self, s: &str) -> Result<String> {
430        let n = s.trim();
431        let (ret, n) = match n.strip_prefix('-') {
432            Some(rest) => (NEGWORD, rest),
433            None => ("", n),
434        };
435
436        if let Some((left, right)) = n.split_once('.') {
437            let mut out = String::from(ret);
438            out.push_str(&self.int_to_word(&Self::py_int(left)?));
439            out.push(' ');
440            out.push_str(self.pointword());
441            out.push(' ');
442            // `for digit in right: ret += self._int_to_word(int(digit)) + " "`.
443            // `int(digit)` on each char reproduces the ValueError verbatim
444            // when `right` holds a non-digit (exponential mantissa's 'e').
445            for ch in right.chars() {
446                let d = Self::py_int(&ch.to_string())?;
447                out.push_str(&self.int_to_word(&d));
448                out.push(' ');
449            }
450            Ok(out.trim().to_string())
451        } else {
452            // `else:` branch — `(ret + self._int_to_word(int(n))).strip()`.
453            Ok(format!("{}{}", ret, self.int_to_word(&Self::py_int(n)?))
454                .trim()
455                .to_string())
456        }
457    }
458}
459
460/// Python's `str(float)` for a finite-or-not f64, sign included.
461///
462/// CPython's `float.__str__` is the shortest round-trip repr, printed in
463/// fixed notation while the decimal point sits in `-3 <= decpt <= 16` and in
464/// exponent notation outside it — i.e. e-form iff `|v| >= 1e16` or
465/// `0 < |v| < 1e-4` (`str(1e16)` == `"1e+16"`, `str(1e15)` ==
466/// `"1000000000000000.0"`, `str(0.0001)` == `"0.0001"`, `str(1e-05)` ==
467/// `"1e-05"`).
468///
469/// * **Fixed form** — `format!("{:.*}", precision, mag)`: `precision` is the
470///   repr-derived fractional digit count the binding carried over
471///   (`abs(Decimal(repr(v)).as_tuple().exponent)`), and correctly rounding
472///   the *exact* f64 to that many places reproduces the shortest-repr digits
473///   byte for byte (both sides are IEEE-754 doubles).
474/// * **Exponent form** — Rust's `{:e}` is shortest-round-trip too
475///   (`"1e16"`, `"1.23e-5"`); reformatted to Python's spelling: explicit
476///   `+`, exponent zero-padded to two digits (`1e+16`, `1e-05`, `1e+100`).
477/// * **inf/nan** — `str(float("inf"))` == `"inf"`, `str(float("nan"))` ==
478///   `"nan"` (CPython prints NaN unsigned regardless of the sign bit).
479///
480/// The sign comes from the *bit*, not a `< 0` compare: `str(-0.0)` is
481/// `"-0.0"`, which is exactly why KM renders the negword for negative zero.
482fn py_float_str(value: f64, precision: u32) -> String {
483    if value.is_nan() {
484        return "nan".to_string();
485    }
486    let neg = value.is_sign_negative();
487    let mag = value.abs();
488    let body = if mag.is_infinite() {
489        "inf".to_string()
490    } else if mag != 0.0 && !(1e-4..1e16).contains(&mag) {
491        // repr picks exponent form. {:e} gives e.g. "1e16" / "9.99e-5".
492        let s = format!("{:e}", mag);
493        let (mant, exp) = s.split_once('e').expect("{:e} always contains 'e'");
494        let e: i32 = exp.parse().expect("{:e} exponent is a valid i32");
495        format!("{}e{}{:02}", mant, if e < 0 { '-' } else { '+' }, e.abs())
496    } else {
497        format!("{:.*}", precision as usize, mag)
498    };
499    if neg {
500        format!("-{}", body)
501    } else {
502        body
503    }
504}
505
506/// `str(number)` for the value the dispatcher handed over: `repr(float)` via
507/// [`py_float_str`], `str(Decimal)` via the spec port in `strnum` (which
508/// keeps trailing zeros — `"5.00"` — and exponent forms — `"1E+2"`).
509fn py_str(value: &FloatValue) -> String {
510    match value {
511        FloatValue::Float { value, precision } => py_float_str(*value, *precision),
512        FloatValue::Decimal { value, .. } => python_decimal_str(value),
513    }
514}
515
516impl Default for LangKm {
517    fn default() -> Self {
518        Self::new()
519    }
520}
521
522impl Lang for LangKm {
523    /// This language's own `to_currency(currency=...)` default,
524    /// read from the live Python signature. Only 44 of 156 use EUR.
525    fn default_currency(&self) -> &str {
526        "KHR"
527    }
528
529    /// This language's own `to_currency(separator=...)` default,
530    /// read from the live Python signature. Base's is ",", but only
531    /// 36 of 149 languages actually use it — most default to " " or a
532    /// conjunction, so inheriting Base's comma silently corrupts them.
533    fn default_separator(&self) -> &str {
534        " "
535    }
536
537    fn negword(&self) -> &str {
538        NEGWORD
539    }
540
541    fn pointword(&self) -> &str {
542        "ចំណុច"
543    }
544
545    /// `Num2Word_KM.to_cardinal`.
546    ///
547    /// Python works on `str(number).strip()`, detaches a leading `"-"`, and —
548    /// because integer input never contains `"."` — falls into
549    /// `(ret + self._int_to_word(int(n))).strip()`.
550    ///
551    /// Splitting the sign off the string is equivalent to `abs()` here:
552    /// `str()` of an int yields at most one leading `"-"`, and there is no
553    /// negative zero. The trailing `.strip()` is likewise a no-op for integers
554    /// (`_int_to_word` never returns an empty or space-padded string, and
555    /// NEGWORD's trailing space is always followed by a word), but it is
556    /// applied anyway to keep the shape of the original.
557    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
558        let ret = if value.is_negative() { NEGWORD } else { "" };
559        let out = format!("{}{}", ret, self.int_to_word(&value.abs()));
560        Ok(out.trim().to_string())
561    }
562
563    /// `"ទី" + self.to_cardinal(number)`. No `verify_ordinal`, so negatives
564    /// and the >= 10^9 digit fallback both flow straight through — bug 3.
565    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
566        Ok(format!("{}{}", ORDINAL_PREFIX, self.to_cardinal(value)?))
567    }
568
569    /// `"ទី" + str(number)`. Note KM overrides the base's `to_ordinal_num`,
570    /// which returns the bare value; the sign is kept, so
571    /// `to_ordinal_num(-1)` == "ទី-1".
572    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
573        Ok(format!("{}{}", ORDINAL_PREFIX, value))
574    }
575
576    /// `"ឆ្នាំ " + self.to_cardinal(val)`. The `longval` parameter is accepted
577    /// and ignored by Python — bug 4.
578    fn to_year(&self, value: &BigInt) -> Result<String> {
579        Ok(format!("{}{}", YEAR_PREFIX, self.to_cardinal(value)?))
580    }
581
582    /// The float/Decimal cardinal path — a direct port of `Num2Word_KM.
583    /// to_cardinal` run on the rebuilt `str(number)`, *not* `float2tuple`.
584    ///
585    /// `precision_override` is dropped on purpose: KM's `to_cardinal` reads
586    /// `str(number)`, never `self.precision`, so `num2words(x, precision=n)`
587    /// leaves the output unchanged (verified against the live interpreter).
588    fn to_cardinal_float(
589        &self,
590        value: &FloatValue,
591        _precision_override: Option<u32>,
592    ) -> Result<String> {
593        self.cardinal_from_pystr(&py_str(value))
594    }
595
596    /// `to_cardinal(float/Decimal)`, whole values included. KM routes on
597    /// `"." in str(number)`, never on `int(value) == value`, so a whole float
598    /// still reads its point digits (`5.0` -> "ប្រាំ ចំណុច សូន្យ",
599    /// `Decimal("5.00")` -> two zero digits) while a point-free `Decimal("5")`
600    /// takes the integer reading — both fall out of the same string surgery,
601    /// so *everything* is sent there, overriding the base whole -> int route.
602    fn cardinal_float_entry(
603        &self,
604        value: &FloatValue,
605        precision_override: Option<u32>,
606    ) -> Result<String> {
607        self.to_cardinal_float(value, precision_override)
608    }
609
610    /// `to_ordinal(float/Decimal)`: `"ទី" + self.to_cardinal(number)` — the
611    /// prefix lands in front of whatever the cardinal produced, negword and
612    /// point digits included ("ទីដក សូន្យ ចំណុច សូន្យ" for -0.0), and the
613    /// cardinal's ValueError on exponential reprs propagates unchanged.
614    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
615        Ok(format!(
616            "{}{}",
617            ORDINAL_PREFIX,
618            self.cardinal_float_entry(value, None)?
619        ))
620    }
621
622    /// `to_ordinal_num(float/Decimal)`: `"ទី" + str(number)`. `repr_str` is
623    /// the binding's Python `str(value)`, so `"ទី1e+16"` / `"ទី5.00"` echo
624    /// exactly — no conversion, no ValueError.
625    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
626        Ok(format!("{}{}", ORDINAL_PREFIX, repr_str))
627    }
628
629    /// `to_year(float/Decimal)`: `"ឆ្នាំ " + self.to_cardinal(val)`, same
630    /// full-cardinal routing (and the same ValueError pass-through).
631    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
632        Ok(format!(
633            "{}{}",
634            YEAR_PREFIX,
635            self.cardinal_float_entry(value, None)?
636        ))
637    }
638
639    /// `converter.str_to_number` — Base's `Decimal(value)`, which KM does not
640    /// override. The Inf/NaN interception reproduces what happens *next* on
641    /// the pinned path: `to_cardinal(Decimal("Infinity"))` reads
642    /// `str(number)` == "Infinity" (canonical capitalization, minus sign
643    /// already peeled by the `startswith("-")` branch), finds no ".", and
644    /// dies in `int("Infinity")` with ValueError; NaN dies in `int("NaN")`.
645    /// The binding otherwise maps `ParsedNumber::Inf` to the base integer
646    /// path's OverflowError (and NaN to a differently-worded ValueError)
647    /// before any KM code runs, so the exact errors must be raised here.
648    ///
649    /// Known gap (unpinned): Python's `to_ordinal_num(Decimal("Infinity"))`
650    /// echoes `"ទីInfinity"`, which this entry-level interception turns into
651    /// the cardinal path's ValueError. The strings corpus pins Infinity/NaN
652    /// under `to=cardinal` only.
653    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
654        match python_decimal_parse(s)? {
655            ParsedNumber::Inf { .. } => Err(N2WError::Value(
656                "invalid literal for int() with base 10: 'Infinity'".into(),
657            )),
658            ParsedNumber::NaN => Err(N2WError::Value(
659                "invalid literal for int() with base 10: 'NaN'".into(),
660            )),
661            other => Ok(other),
662        }
663    }
664
665    // ---- currency ----------------------------------------------------
666
667    fn lang_name(&self) -> &str {
668        "Num2Word_KM"
669    }
670
671    /// `CURRENCY_FORMS[code]` — a *strict* lookup that returns `None` for an
672    /// unknown code.
673    ///
674    /// Note the asymmetry with `to_currency` below, which is not an oversight
675    /// but the whole point: `to_currency` does `.get(currency, ...KHR)` and so
676    /// never fails, while `to_cheque` (inherited from `Num2Word_Base`) does a
677    /// bare `self.CURRENCY_FORMS[currency]` and converts the `KeyError` into
678    /// `NotImplementedError`. This hook backs the *cheque* path, so it must
679    /// keep the strict semantics; the fallback lives in `to_currency` alone.
680    /// That is why `currency:GBP` renders in riel while `cheque:GBP` raises.
681    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
682        self.currency_forms.get(code)
683    }
684
685    /// `Num2Word_KM.to_currency` — overridden wholesale, sharing nothing with
686    /// `Num2Word_Base.to_currency`.
687    ///
688    /// The Python body is string surgery on `str(val)`, not arithmetic:
689    ///
690    /// ```python
691    /// parts = str(val).split(".")
692    /// left = int(parts[0]) if parts[0] else 0
693    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
694    /// ```
695    ///
696    /// Four consequences, all reproduced verbatim:
697    ///
698    /// 1. **`CURRENCY_PRECISION` is never consulted.** There is no divisor and
699    ///    no quantize, so the 3-decimal (KWD/BHD) and 0-decimal (JPY) currencies
700    ///    get no special handling whatsoever — `12.34` is "twelve X thirty-four
701    ///    sen" for every code alike. Because this method replaces the base's,
702    ///    the `divisor == 1` pre-rounding in `default_to_currency` is bypassed
703    ///    too. Corpus rows for KWD/BHD/JPY confirm all three.
704    /// 2. **`[:2].ljust(2, "0")` reads the fraction positionally**, so `0.5`
705    ///    is 50 sen, not 5, and a third decimal is silently truncated
706    ///    (`1.239` -> 23 sen) rather than rounded.
707    /// 3. **`if cents and right:` drops a zero cents segment.** `1.0` is a
708    ///    float, so `Num2Word_Base` would render "one X zero sen" — but here
709    ///    `right == 0` is falsy and the segment vanishes, making `1.0` print
710    ///    identically to the int `1`. The `Int`/`Decimal` split therefore does
711    ///    not change this language's output, though it is still honoured below.
712    /// 4. **`adjective` is accepted and ignored** — `CURRENCY_ADJECTIVES` is
713    ///    empty for KM anyway, so no prefixing can occur.
714    ///
715    /// `cr1[0]`/`cr2[0]` are indexed directly rather than pluralized, so
716    /// `pluralize` (abstract in `Num2Word_Base`) is never reached and stays at
717    /// its raising default.
718    fn to_currency(
719        &self,
720        val: &CurrencyValue,
721        currency: &str,
722        cents: bool,
723        separator: Option<&str>,
724        _adjective: bool,
725    ) -> Result<String> {
726        // Trait now hands us None when the caller omitted separator=;
727        // resolve it to this language's own default before the ported body.
728        let separator = separator.unwrap_or(self.default_separator());
729        // `if val < 0: is_negative = True; val = abs(val)`. Taking `abs` before
730        // stringifying is what Python does, and it is why no operand below can
731        // be negative. `-0.0` is not `< 0` in Python and is not negative here
732        // either, and its `int("-0")` still lands on 0 — the paths agree.
733        let is_negative = val.is_negative();
734
735        // `str(val)`. The value reached Rust as `str(value)` parsed on the
736        // Python side, and `BigDecimal`'s Display round-trips every plain
737        // decimal string bit for bit, so re-rendering it reconstructs exactly
738        // what Python's `str()` handed to `.split(".")`. (Floats whose repr is
739        // exponential are the one gap — see the module concerns.)
740        let text = match val {
741            CurrencyValue::Int(v) => v.abs().to_string(),
742            CurrencyValue::Decimal { value: d, .. } => d.abs().to_string(),
743        };
744
745        // `.split(".")` then index [0]/[1]: only the first dot separates, and
746        // Python's guards make a missing or empty side mean 0.
747        let mut parts = text.splitn(2, '.');
748        let whole_part = parts.next().unwrap_or("");
749        let frac_part = parts.next();
750
751        let left = if whole_part.is_empty() {
752            BigInt::zero()
753        } else {
754            Self::py_int(whole_part)?
755        };
756
757        let right = match frac_part {
758            // `if len(parts) > 1 and parts[1]` — an empty fraction ("12.") is
759            // falsy and yields 0 rather than `int("")`.
760            Some(frac) if !frac.is_empty() => {
761                // `parts[1][:2].ljust(2, "0")`: take two *characters*, then pad
762                // on the right (never the left) so "5" reads as 50 sen.
763                let mut digits: String = frac.chars().take(2).collect();
764                while digits.chars().count() < 2 {
765                    digits.push('0');
766                }
767                Self::py_int(&digits)?
768            }
769            _ => BigInt::zero(),
770        };
771
772        // `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["KHR"])`.
773        // Unknown codes silently become riel; only a missing "KHR" entry could
774        // raise, and Python would raise KeyError there, so that is the variant
775        // used. `new()` always inserts KHR, making this unreachable.
776        let forms = self
777            .currency_forms(currency)
778            .or_else(|| self.currency_forms(KHR))
779            .ok_or_else(|| N2WError::Key(format!("'{}'", KHR)))?;
780
781        // `cr1[0]`/`cr2[0]`: always the first form, never pluralized. Both
782        // entries are 2-tuples built in `new()`, so the index is provably safe.
783        let mut result = format!("{} {}", self.int_to_word(&left), forms.unit[0]);
784
785        if cents && !right.is_zero() {
786            result.push_str(separator);
787            result.push_str(&self.int_to_word(&right));
788            result.push(' ');
789            result.push_str(&forms.subunit[0]);
790        }
791
792        // `(self.negword if is_negative else "") + result` — the raw negword,
793        // trailing space and all. Unlike `Num2Word_Base.to_currency`, which
794        // does `negword.strip() + " "`, KM concatenates it unmodified. Same
795        // bytes here, but ported as written.
796        Ok(if is_negative {
797            format!("{}{}", NEGWORD, result)
798        } else {
799            result
800        })
801    }
802}
803
804#[cfg(test)]
805mod float_tests {
806    use super::*;
807    use bigdecimal::BigDecimal;
808
809    fn dec(s: &str) -> FloatValue {
810        let bd = BigDecimal::from_str(s).unwrap();
811        let precision = bd.as_bigint_and_exponent().1.unsigned_abs() as u32;
812        FloatValue::Decimal { value: bd, precision }
813    }
814
815    #[test]
816    fn whole_floats_keep_their_point_digits() {
817        let km = LangKm::new();
818        // "." in str(5.0) -> the float grammar, never the int path.
819        assert_eq!(
820            km.cardinal_float_entry(&FloatValue::Float { value: 5.0, precision: 1 }, None)
821                .unwrap(),
822            "ប្រាំ ចំណុច សូន្យ"
823        );
824        // str(-0.0) begins "-": the sign bit keeps the negword.
825        assert_eq!(
826            km.cardinal_float_entry(&FloatValue::Float { value: -0.0, precision: 1 }, None)
827                .unwrap(),
828            "ដក សូន្យ ចំណុច សូន្យ"
829        );
830        // Decimal("5.00") reads both trailing zeros; Decimal("5") does not.
831        assert_eq!(
832            km.cardinal_float_entry(&dec("5.00"), None).unwrap(),
833            "ប្រាំ ចំណុច សូន្យ សូន្យ"
834        );
835        assert_eq!(km.cardinal_float_entry(&dec("5"), None).unwrap(), "ប្រាំ");
836        // >= 10^9 whole part hits _int_to_word's digit fallback (bug 1).
837        assert_eq!(
838            km.cardinal_float_entry(
839                &FloatValue::Float { value: 1_000_000_000.0, precision: 1 },
840                None
841            )
842            .unwrap(),
843            "1000000000 ចំណុច សូន្យ"
844        );
845    }
846
847    #[test]
848    fn float_entries_prefix_like_python() {
849        let km = LangKm::new();
850        let v = FloatValue::Float { value: -3.0, precision: 1 };
851        assert_eq!(
852            km.ordinal_float_entry(&v).unwrap(),
853            "ទីដក បី ចំណុច សូន្យ"
854        );
855        assert_eq!(km.year_float_entry(&v).unwrap(), "ឆ្នាំ ដក បី ចំណុច សូន្យ");
856        assert_eq!(km.ordinal_num_float_entry(&v, "-3.0").unwrap(), "ទី-3.0");
857    }
858
859    #[test]
860    fn exponential_reprs_raise_value_error() {
861        let km = LangKm::new();
862        // str(1e16) == "1e+16": no ".", int("1e+16") -> ValueError.
863        let e16 = FloatValue::Float { value: 1e16, precision: 16 };
864        assert!(matches!(
865            km.cardinal_float_entry(&e16, None),
866            Err(N2WError::Value(_))
867        ));
868        // str(Decimal("1E+2")) == "1E+2": int("1E+2") -> ValueError.
869        assert!(matches!(
870            km.year_float_entry(&dec("1E+2")),
871            Err(N2WError::Value(_))
872        ));
873        // ordinal_num echoes the repr instead of converting.
874        assert_eq!(km.ordinal_num_float_entry(&e16, "1e+16").unwrap(), "ទី1e+16");
875    }
876
877    #[test]
878    fn python_float_str_matches_cpython() {
879        assert_eq!(py_float_str(5.0, 1), "5.0");
880        assert_eq!(py_float_str(-0.0, 1), "-0.0");
881        assert_eq!(py_float_str(1e15, 1), "1000000000000000.0");
882        assert_eq!(py_float_str(1e16, 16), "1e+16");
883        assert_eq!(py_float_str(1e20, 20), "1e+20");
884        assert_eq!(py_float_str(0.0001, 4), "0.0001");
885        assert_eq!(py_float_str(1e-5, 5), "1e-05");
886        assert_eq!(py_float_str(f64::INFINITY, 0), "inf");
887        assert_eq!(py_float_str(f64::NAN, 0), "nan");
888    }
889
890    #[test]
891    fn infinity_string_raises_value_error() {
892        let km = LangKm::new();
893        assert!(matches!(km.str_to_number("Infinity"), Err(N2WError::Value(_))));
894        assert!(matches!(km.str_to_number("-Infinity"), Err(N2WError::Value(_))));
895        assert!(matches!(km.str_to_number("NaN"), Err(N2WError::Value(_))));
896        assert!(matches!(km.str_to_number("1.5"), Ok(ParsedNumber::Dec(_))));
897    }
898}