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}