num2words2_core/lang_mr.rs
1//! Port of `lang_MR.py` (Marathi).
2//!
3//! Shape: **self-contained**. `Num2Word_MR` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL`. Every in-scope mode is
6//! overridden outright and driven by `_int_to_word`, a plain recursive
7//! descent over the Indian scale system (हजार / लाख / कोटी / अब्ज / खर्व).
8//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here.
9//!
10//! **There is no overflow check and no ceiling.** Because `MAXVAL` is never
11//! set, the `value >= self.MAXVAL` guard in `Num2Word_Base.to_cardinal` is
12//! never reached (the override skips it), and the top `खर्व` branch recurses
13//! on `number // 10**11`, which re-enters the same branch for large enough
14//! inputs. So `10**22` is "एक खर्व खर्व" rather than an `OverflowError`, and
15//! arbitrarily large `BigInt`s terminate by repeated division. None of the
16//! four in-scope modes can raise: every list index is guarded by the range
17//! check that selects the branch, so there is no `IndexError`/`KeyError`
18//! path here.
19//!
20//! Inherited from `Num2Word_Base`:
21//! * `setup()` sets only `negword = "ऋण "` (note the **trailing space**)
22//! and `pointword = "दशांश"`. Nothing else is touched, so `is_title`
23//! stays `False` — and MR's `to_cardinal` never calls `self.title`
24//! anyway.
25//!
26//! # Faithfully reproduced Python oddities
27//!
28//! This is a port, not a rewrite. The following all look wrong but are
29//! exactly what Python emits, and every one is confirmed against the frozen
30//! corpus:
31//!
32//! 1. **Only the 20s compound.** `_int_to_word` glues the unit onto "वीस"
33//! for 21..29 ("एकवीस", "पाचवीस", "चारवीस"), but every other decade is
34//! emitted as two space-separated words in the wrong order for Marathi:
35//! 31 is "तीस एक" (lit. "thirty one"), 42 "चाळीस दोन", 99 the hardcoded
36//! "नव्याण्णव". Real Marathi would be एकतीस / बेचाळीस. Preserved verbatim.
37//! 2. **The 20s compounds are themselves malformed.** 22 → `ones[2] + "वीस"`
38//! = "दोनवीस" (Marathi: बावीस), 23 → "तीनवीस" (Marathi: तेवीस). Only 21
39//! ("एकवीस") happens to come out right.
40//! 3. **The `number == 21` special case is dead code.** It produces
41//! `"एक" + tens[2]`, but `ones[21 % 10]` is *already* "एक", so both arms
42//! of the conditional agree. Reproduced anyway — see [`int_to_word`].
43//! 4. **`99` is special-cased but `98`/`97`/... are not**, so 99 is
44//! "नव्याण्णव" while 98 is "नव्वद आठ".
45//! 5. **`to_ordinal` suffixes the raw cardinal**, so negatives and zero
46//! produce nonsense rather than raising: `to_ordinal(0)` == "शून्यवा",
47//! `to_ordinal(-21)` == "ऋण एकवीसवा", `to_ordinal(-1000)` ==
48//! "ऋण एक हजारवा". `Num2Word_Base.verify_ordinal` (which would raise
49//! `TypeError` on a negative) is never called.
50//! 6. **`to_ordinal_num` mixes numeral systems.** 1..4 return Devanagari
51//! digits ("१ला", "२रा", "३रा", "४था"); everything else falls through to
52//! `str(number) + "वा"`, which is ASCII — hence "5वा", "10वा", "0वा" and
53//! even "-1वा". Also 2 and 3 share the suffix "रा".
54//! 7. **`to_year` ignores its `longval` parameter** and just prefixes the
55//! cardinal, so there is no two-digit-pair year reading: 1905 is
56//! "सन एक हजार नऊशे पाच", not "nineteen oh five". `to_year(0)` is
57//! "सन शून्य" (0 is not `< 0`).
58//!
59//! 8. **`to_currency` falls back to rupees for every unknown code** rather
60//! than raising, because it looks the code up with
61//! `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["INR"])`. The inherited
62//! `to_cheque` uses a strict `CURRENCY_FORMS[currency]` subscript instead,
63//! so the *same* code (JPY, CHF, ...) renders happily as रुपये through
64//! `to_currency` but raises NotImplementedError through `to_cheque`. Both
65//! halves of that contradiction are in the corpus.
66//!
67//! # Currency shape
68//!
69//! `to_currency` is overridden outright and shares nothing with
70//! `Num2Word_Base`'s currency machinery — see [`LangMr::to_currency`].
71//! `to_cheque` is *not* overridden, so it comes from `Num2Word_Base` and needs
72//! only `lang_name` + `currency_forms` + the default `money_verbose` (which
73//! routes to MR's `to_cardinal`) to reproduce
74//! "एक हजार दोनशे तीस चार AND 56/100 युरो". `.upper()` is a no-op on
75//! Devanagari — it is caseless — so only the literal "AND" looks upper-cased.
76//! `pluralize` is never reached from either path and correctly keeps the
77//! trait's raising default (`Num2Word_Base.pluralize` raises
78//! NotImplementedError too).
79//!
80//! Note also that the `number < 0` arm of `_int_to_word` (which would return
81//! `negword + _int_to_word(abs(n))` with **no** `.strip()`) is unreachable
82//! from every in-scope entry point: `to_cardinal` detaches the sign before
83//! calling in, and `to_ordinal`/`to_year` both route through `to_cardinal`.
84//! It is reproduced in [`int_to_word`] for fidelity regardless.
85
86use crate::base::{Lang, N2WError, Result};
87use crate::currency::{CurrencyForms, CurrencyValue};
88use crate::floatpath::FloatValue;
89use crate::strnum::{python_decimal_parse, ParsedNumber};
90use bigdecimal::BigDecimal;
91use num_bigint::BigInt;
92use num_integer::Integer;
93use num_traits::{Signed, ToPrimitive, Zero};
94use std::collections::HashMap;
95
96/// `self.negword`, set by `setup()`. The trailing space is load-bearing:
97/// `to_cardinal` concatenates it directly (`ret + word`) rather than joining
98/// with a separator, unlike `Num2Word_Base`, which does `negword.strip() + " "`.
99const NEGWORD: &str = "ऋण ";
100
101/// `self.pointword`, set by `setup()`. Reached only on the float/Decimal
102/// path, where `Num2Word_MR.to_cardinal` splices it between the integer and
103/// the digit-by-digit fraction (see [`cardinal_from_str`]). `is_title` is
104/// `False`, and MR uses `self.pointword` directly rather than `self.title(...)`,
105/// so it is emitted verbatim.
106const POINTWORD: &str = "दशांश";
107
108const ZERO_WORD: &str = "शून्य";
109
110/// `ones`. Index 0 is "" and is only ever selected when the caller has
111/// already established `number % 10 != 0` (or `number != 0`), so the empty
112/// string never reaches the output.
113const ONES: [&str; 10] = [
114 "", "एक", "दोन", "तीन", "चार", "पाच", "सहा", "सात", "आठ", "नऊ",
115];
116
117/// `tens`. Index 0 is unreachable (`number >= 20` in the only branch that
118/// reads this table) and index 1 is dead too — 10..19 are handled by
119/// [`TEENS`] before the `< 100` branch is ever entered.
120const TENS: [&str; 10] = [
121 "", "दहा", "वीस", "तीस", "चाळीस", "पन्नास", "साठ", "सत्तर", "ऐंशी", "नव्वद",
122];
123
124/// `teens`, indexed by `number - 10` for 10..=19.
125const TEENS: [&str; 10] = [
126 "दहा", "अकरा", "बारा", "तेरा", "चौदा", "पंधरा", "सोळा", "सतरा", "अठरा", "एकोणीस",
127];
128
129/// Hardcoded in the `number // 10 == 9 and number % 10 == 9` arm.
130const NINETY_NINE: &str = "नव्याण्णव";
131
132/// Suffix appended by the `< 1000` branch: `ones[number // 100] + "शे"`.
133const HUNDRED_SUFFIX: &str = "शे";
134
135/// Generic ordinal suffix appended to the cardinal for anything above 10.
136const ORDINAL_SUFFIX: &str = "वा";
137
138/// `to_ordinal`'s irregular forms for 1..=10. Python tests `number == k`
139/// with plain `==`, which is *numeric*, so these fire for equal
140/// floats/Decimals too: `to_ordinal(1.0)` is "पहिला" and
141/// `to_ordinal(Decimal("5.00"))` is "पाचवा" — the ".0" tail vanishes
142/// entirely. Shared by [`LangMr::to_ordinal`] (int path) and
143/// [`LangMr::ordinal_float_entry`] (float/Decimal path).
144const ORDINAL_SPECIALS: [(u64, &str); 10] = [
145 (1, "पहिला"),
146 (2, "दुसरा"),
147 (3, "तिसरा"),
148 (4, "चौथा"),
149 (5, "पाचवा"),
150 (6, "सहावा"),
151 (7, "सातवा"),
152 (8, "आठवा"),
153 (9, "नववा"),
154 (10, "दहावा"),
155];
156
157const YEAR_PREFIX: &str = "सन ";
158const YEAR_BC_PREFIX: &str = "इसवीसन पूर्व ";
159
160/// `Num2Word_MR.to_currency`'s own default `separator=" आणि "`.
161///
162/// See [`SEPARATOR_UNSET`] for why this cannot simply be a parameter default.
163const SEPARATOR_DEFAULT: &str = " आणि ";
164
165/// The separator the pyo3 binding passes when the Python caller omitted one.
166///
167/// `Num2Word_MR.to_currency` declares `separator=" आणि "`, but the `Lang` trait
168/// has no per-language defaults: `__init__.py`'s fast path (and
169/// `bench/diff_test.py`) substitute `kwargs.get("separator", ",")` —
170/// **`Num2Word_Base`'s** default — before the value ever reaches Rust. By then
171/// "caller omitted separator" and "caller explicitly passed a comma" are the
172/// same string, and the information needed to tell them apart no longer exists
173/// on this side of the boundary.
174///
175/// So `,` is read back as the unset sentinel and MR's own default restored.
176/// This is the only reading that matches the oracle: every float row of the
177/// `mr` currency corpus was generated by `num2words(v, lang="mr",
178/// to="currency", currency=c)` with no `separator=`, and each one expects
179/// " आणि " (e.g. `12.34` -> "बारा युरो आणि तीस चार सेंट्स").
180///
181/// The cost is narrow and known: a caller who *explicitly* passes
182/// `separator=","` gets " आणि " here where Python would give ",". Fixing that
183/// properly needs `Option<&str>` in the trait signature, which lives in
184/// `base.rs` — outside this port's remit. Flagged in the port report.
185/// `lang_ca.rs` and `lang_es.rs` resolve the identical conflict the same way.
186const SEPARATOR_UNSET: &str = ",";
187
188/// The fallback code for `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["INR"])`.
189const CURRENCY_FALLBACK: &str = "INR";
190
191fn bi(n: u64) -> BigInt {
192 BigInt::from(n)
193}
194
195/// Narrow a `BigInt` the caller has already range-checked below 1000.
196///
197/// Every call site sits inside a `number < 100` or `number < 1000` guard and
198/// passes either the value itself or a quotient of it, so the conversion
199/// cannot fail.
200fn small(n: &BigInt) -> usize {
201 n.to_usize()
202 .expect("caller guarantees this value is range-checked below 1000")
203}
204
205/// One Indian-scale group: `_int_to_word(number // divisor) + " " + word`,
206/// then `" " + _int_to_word(remainder)` when the remainder is non-zero.
207///
208/// Shared by the हजार / लाख / कोटी / अब्ज / खर्व branches, which are
209/// character-for-character identical in Python apart from the two constants.
210fn scale(number: &BigInt, divisor: u64, word: &str) -> String {
211 let (div, rem) = number.div_mod_floor(&bi(divisor));
212 let mut result = format!("{} {}", int_to_word(&div), word);
213 if !rem.is_zero() {
214 result.push(' ');
215 result.push_str(&int_to_word(&rem));
216 }
217 result
218}
219
220/// Port of `Num2Word_MR._int_to_word`.
221///
222/// Python checks `number == 0` first, then the tables are (re)declared, then
223/// the `number < 0` arm runs — so every branch below the sign check operates
224/// on a non-negative value and Python's floor `//`/`%` coincide with
225/// truncating division. `div_mod_floor` matches Python exactly either way.
226fn int_to_word(number: &BigInt) -> String {
227 if number.is_zero() {
228 return ZERO_WORD.to_string();
229 }
230
231 // Unreachable from to_cardinal/to_ordinal/to_year (all detach the sign
232 // first), but reproduced as written — note there is no .strip() here.
233 if number.is_negative() {
234 return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
235 }
236
237 if number < &bi(10) {
238 return ONES[small(number)].to_string();
239 }
240
241 if number < &bi(20) {
242 return TEENS[small(number) - 10].to_string();
243 }
244
245 if number < &bi(100) {
246 let n = small(number);
247 let tens_digit = n / 10;
248 let ones_digit = n % 10;
249 let mut result = TENS[tens_digit].to_string();
250 if ones_digit != 0 {
251 if tens_digit == 2 {
252 // Python: `"एक" + tens[2] if number == 21 else ones[number % 10] + tens[2]`
253 // — the conditional binds looser than `+`, so both arms
254 // concatenate onto tens[2]. The n == 21 arm is dead code:
255 // ONES[1] is already "एक". Kept for fidelity.
256 result = if n == 21 {
257 format!("एक{}", TENS[2])
258 } else {
259 format!("{}{}", ONES[ones_digit], TENS[2])
260 };
261 } else if tens_digit == 9 && ones_digit == 9 {
262 result = NINETY_NINE.to_string();
263 } else {
264 // Python: `result += " " + ones[number % 10] if number % 10 else ""`.
265 // The trailing conditional is redundant — we are already
266 // inside `if number % 10:` — so the else-branch never fires.
267 result.push(' ');
268 result.push_str(ONES[ones_digit]);
269 }
270 }
271 return result;
272 }
273
274 if number < &bi(1000) {
275 let (div, rem) = number.div_mod_floor(&bi(100));
276 let mut result = format!("{}{}", ONES[small(&div)], HUNDRED_SUFFIX);
277 if !rem.is_zero() {
278 result.push(' ');
279 result.push_str(&int_to_word(&rem));
280 }
281 return result;
282 }
283
284 if number < &bi(100_000) {
285 // Indian lakh system: thousands run 1_000..99_999.
286 return scale(number, 1_000, "हजार");
287 }
288
289 if number < &bi(10_000_000) {
290 return scale(number, 100_000, "लाख");
291 }
292
293 if number < &bi(1_000_000_000) {
294 // "Less than 100 crore" — the quotient here reaches 99.
295 return scale(number, 10_000_000, "कोटी");
296 }
297
298 if number < &bi(100_000_000_000) {
299 return scale(number, 1_000_000_000, "अब्ज");
300 }
301
302 // Kharab, and the only unbounded branch: 10**22 // 10**11 == 10**11
303 // lands right back here, yielding "एक खर्व खर्व".
304 scale(number, 100_000_000_000, "खर्व")
305}
306
307// ---- float / Decimal path -------------------------------------------------
308//
309// `Num2Word_MR.to_cardinal` does NOT use `Num2Word_Base.to_cardinal_float`.
310// It overrides `to_cardinal` and handles non-integers *inline* off
311// `str(number)`:
312//
313// ```python
314// n = str(number).strip()
315// if n.startswith("-"):
316// n = n[1:]; ret = self.negword
317// else:
318// ret = ""
319// if "." in n:
320// left, right = n.split(".", 1)
321// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
322// ret += " ".join(self._int_to_word(int(d)) for d in right)
323// return ret.strip()
324// else:
325// return (ret + self._int_to_word(int(n))).strip()
326// ```
327//
328// So the fractional part is the *literal digit string* of `str(number)`
329// spoken one digit at a time — never a `post` integer, never zero-padded to a
330// precision, and the `base.float2tuple` `< 0.01` heuristic never runs. The
331// entire specification is therefore Python's `repr(float)` / `str(Decimal)`,
332// reproduced by [`py_float_repr`] / [`py_decimal_str`]. Two behaviours fall
333// straight out of that and are exactly what Python does, but which the
334// inherited base path gets wrong (which is why MR needs its own override):
335//
336// * **negative zero keeps its sign.** `str(-0.0)` == "-0.0" and
337// `str(Decimal("-0.00"))` == "-0.00" both start with "-", so MR prepends
338// `negword` even though the value is not `< 0`. Base drops it (its guard
339// is `value < 0 and pre == 0`).
340// * **scientific notation raises `ValueError`.** For `abs(f) >= 1e16` or a
341// nonzero `abs(f) < 1e-4`, `repr(float)` switches to exponent form; MR
342// then feeds a token containing `e`/`E`/`+` to `int()` (either `int(n)`
343// with no dot, or `int(d)` mid-fraction) and Python raises. Base renders
344// it instead.
345//
346// `precision` (and the `precision=` kwarg) is set on the converter by
347// `__init__.py` but MR's `to_cardinal` never reads `self.precision`, so it is
348// ignored here too — `precision_override` is dropped in `to_cardinal_float`.
349//
350// These helpers mirror the already-verified `lang_as.rs` / `lang_gu.rs`
351// (Assamese / Gujarati) versions verbatim; those languages share MR's exact
352// `str(number)`-driven shape.
353
354/// Shortest round-trip decimal digits of `a` and its decimal point position,
355/// matching CPython/Gay's dtoa. Returns `(digits, decpt)` where the value is
356/// `0.<digits> * 10**decpt`.
357///
358/// Rust's `{:e}` is also shortest-round-trip and agrees with dtoa on the digit
359/// *count* and almost always the digits; it disagrees only on **exact ties**,
360/// where dtoa picks the candidate with an even last digit while Rust rounds
361/// half up. The block below detects that tie with no bignum (write
362/// `a = m * 2**e` with `m` odd, `q = digits.len() - decpt`; the tie is
363/// `e + q + 1 == 0`, plus `5**-q | m` when `q < 0`) and steps to the even
364/// neighbour, exactly as `lang_as.rs` documents and fuzzes.
365fn shortest_digits(a: f64) -> (String, i32) {
366 let sci = format!("{:e}", a);
367 let (mant, exp) = sci
368 .split_once('e')
369 .expect("{:e} on a finite f64 always emits an exponent");
370 let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
371 let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
372
373 // Decompose a == m * 2**e exactly, then reduce m to odd.
374 let bits = a.to_bits();
375 let biased = ((bits >> 52) & 0x7ff) as i32;
376 let frac = bits & ((1u64 << 52) - 1);
377 let (mut m, mut e) = if biased == 0 {
378 (frac, -1074i32) // subnormal: no implicit leading bit
379 } else {
380 (frac | (1u64 << 52), biased - 1075)
381 };
382 if m == 0 {
383 // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
384 return (String::from_utf8(digits).expect("ASCII digits"), decpt);
385 }
386 let z = m.trailing_zeros() as i32;
387 m >>= z;
388 e += z;
389
390 let q = digits.len() as i32 - decpt;
391 let mut tie = e + q + 1 == 0;
392 if tie && q < 0 {
393 let r = -q as u32;
394 tie = r <= 22 && m % 5u64.pow(r) == 0;
395 }
396 if !tie {
397 return (String::from_utf8(digits).expect("ASCII digits"), decpt);
398 }
399
400 let last = digits[digits.len() - 1] - b'0';
401 if last % 2 == 1 {
402 if m % 4 == 1 {
403 // k even: Python wants k, Rust gave k+1. Last digit is odd (hence
404 // non-zero), so this never borrows.
405 *digits.last_mut().expect("non-empty") -= 1;
406 } else {
407 // k odd: Python wants k+1, Rust gave k. Carry like dtoa's roundoff.
408 let mut i = digits.len();
409 loop {
410 if i == 0 {
411 digits.insert(0, b'1');
412 decpt += 1;
413 break;
414 }
415 i -= 1;
416 if digits[i] == b'9' {
417 digits[i] = b'0';
418 } else {
419 digits[i] += 1;
420 break;
421 }
422 }
423 }
424 while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
425 digits.pop();
426 }
427 }
428 (String::from_utf8(digits).expect("ASCII digits"), decpt)
429}
430
431/// Python's `str(float)` (== `repr(float)`), CPython's
432/// `format_float_short(..., 'r', ...)`. Rust's own `{}` cannot stand in: it
433/// never switches to exponent notation and prints `1`, not `1.0`, for integral
434/// floats. Rules from `format_float_short`: exponent notation iff
435/// `decpt <= -4 || decpt > 16`; the exponent is `%+.02d`; a trailing `.0` is
436/// appended to an otherwise-integral fixed result but never in exponent form;
437/// `nan` drops its sign, `inf` keeps it.
438fn py_float_repr(value: f64) -> String {
439 if value.is_nan() {
440 return "nan".to_string();
441 }
442 if value.is_infinite() {
443 return if value > 0.0 { "inf" } else { "-inf" }.to_string();
444 }
445 // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0", and the sign is
446 // stripped textually into a negword.
447 let sign = if value.is_sign_negative() { "-" } else { "" };
448 let (digits, decpt) = shortest_digits(value.abs());
449 let ndigits = digits.len() as i32;
450
451 if decpt <= -4 || decpt > 16 {
452 let exp = decpt - 1;
453 let mut mant = String::from(&digits[..1]);
454 if digits.len() > 1 {
455 mant.push('.');
456 mant.push_str(&digits[1..]);
457 }
458 format!(
459 "{}{}e{}{:02}",
460 sign,
461 mant,
462 if exp < 0 { '-' } else { '+' },
463 exp.abs()
464 )
465 } else if decpt <= 0 {
466 format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
467 } else if decpt >= ndigits {
468 format!(
469 "{}{}{}.0",
470 sign,
471 digits,
472 "0".repeat((decpt - ndigits) as usize)
473 )
474 } else {
475 let d = decpt as usize;
476 format!("{}{}.{}", sign, &digits[..d], &digits[d..])
477 }
478}
479
480/// Python's `str(Decimal)` — `Decimal.__str__` with the default context.
481///
482/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
483/// with `_exp == -scale`; the shim builds the value with
484/// `BigDecimal::from_str(str(value))`, which preserves trailing zeros and
485/// negative exponents (so `"1.10"` round-trips as `(110, 2)`). The
486/// `leftdigits > -6` guard keeps every `"0".repeat(...)` bounded.
487///
488/// Caveat (same as `lang_as.rs`): a `BigDecimal` cannot represent `-0.0`, so a
489/// fixed-notation negative zero loses its negword here. Beyond the exponent
490/// threshold `int()` raises `ValueError` with a message that agrees either way.
491/// Flagged in the port report.
492fn py_decimal_str(value: &BigDecimal) -> String {
493 let (int_val, scale) = value.as_bigint_and_exponent();
494 // i128 so that `-scale` cannot overflow for a pathological i64::MIN scale.
495 let exp = -(scale as i128);
496 let sign = if int_val.is_negative() { "-" } else { "" };
497 let int_digits = int_val.abs().to_string(); // Decimal._int
498 let len = int_digits.len() as i128;
499
500 let leftdigits = exp + len;
501 let dotplace = if exp <= 0 && leftdigits > -6 {
502 leftdigits
503 } else {
504 1
505 };
506
507 let (intpart, fracpart) = if dotplace <= 0 {
508 (
509 "0".to_string(),
510 format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
511 )
512 } else if dotplace >= len {
513 (
514 format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
515 String::new(),
516 )
517 } else {
518 let d = dotplace as usize;
519 (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
520 };
521
522 let expstr = if leftdigits == dotplace {
523 String::new()
524 } else {
525 // "%+d" — signed, but not zero-padded, unlike repr(float)'s "%+.02d".
526 let d = leftdigits - dotplace;
527 format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
528 };
529
530 format!("{}{}{}{}", sign, intpart, fracpart, expstr)
531}
532
533/// Python's `int(s)` for the ASCII fragments [`cardinal_from_str`] hands it.
534/// Ports the underscore rule (`int("1_0") == 10`, `int("1_")` raises) and the
535/// error message (`repr(s)` of the original argument).
536fn py_int(s: &str) -> Result<BigInt> {
537 let err = || {
538 N2WError::Value(format!(
539 "invalid literal for int() with base 10: '{}'",
540 s
541 ))
542 };
543 let t = s.trim();
544 let (negative, body) = match t.strip_prefix('-') {
545 Some(rest) => (true, rest),
546 None => (false, t.strip_prefix('+').unwrap_or(t)),
547 };
548 if body.is_empty()
549 || body.starts_with('_')
550 || body.ends_with('_')
551 || body.contains("__")
552 || !body.chars().all(|c| c.is_ascii_digit() || c == '_')
553 {
554 return Err(err());
555 }
556 let digits: String = body.chars().filter(|c| *c != '_').collect();
557 let n: BigInt = digits.parse().map_err(|_| err())?;
558 Ok(if negative { -n } else { n })
559}
560
561/// The body of `Num2Word_MR.to_cardinal` for a stringified number `n`, minus
562/// the outer `.strip()` (applied by the caller). The sign is stripped
563/// *textually* (so "-0.0" keeps its `negword`), `split(".", 1)` caps at one
564/// split (a second dot detonates in the digit loop), and `int(left)` runs
565/// before the digit generator so a malformed left half is the first to raise.
566fn cardinal_from_str(number: &str) -> Result<String> {
567 let n = number.trim();
568 let (n, mut ret) = match n.strip_prefix('-') {
569 // ret = self.negword — "ऋण " already carries its own trailing space.
570 Some(rest) => (rest, NEGWORD.to_string()),
571 None => (n, String::new()),
572 };
573
574 let Some(dot) = n.find('.') else {
575 ret.push_str(&int_to_word(&py_int(n)?));
576 return Ok(ret);
577 };
578
579 // n.split(".", 1) — maxsplit=1, so `right` keeps any further dots.
580 let (left, right) = (&n[..dot], &n[dot + 1..]);
581 ret.push_str(&int_to_word(&py_int(left)?));
582 ret.push(' ');
583 ret.push_str(POINTWORD);
584 ret.push(' ');
585
586 // " ".join(self._int_to_word(int(d)) for d in right) — iterate *characters*.
587 let mut first = true;
588 for d in right.chars() {
589 if !first {
590 ret.push(' ');
591 }
592 first = false;
593 let mut buf = [0u8; 4];
594 ret.push_str(&int_to_word(&py_int(d.encode_utf8(&mut buf))?));
595 }
596 Ok(ret)
597}
598
599/// Marathi (`mr`).
600///
601/// Stateless apart from the immutable `CURRENCY_FORMS` table — Python's
602/// `Num2Word_MR` carries no cross-call flags (`setup` assigns two string
603/// constants and nothing mutates them afterwards, and `CURRENCY_FORMS` is a
604/// class attribute that is only ever read).
605pub struct LangMr {
606 /// `Num2Word_MR.CURRENCY_FORMS`, built once. `Num2Word_Base` declares an
607 /// empty dict and MR replaces it outright — there is no `__init__` merge
608 /// step in this class, and MR inherits neither `CURRENCY_ADJECTIVES` nor
609 /// `CURRENCY_PRECISION` overrides (both stay `{}`), so the trait defaults
610 /// for `currency_adjective` (None) and `currency_precision` (100) are
611 /// already correct and are deliberately not overridden.
612 currency_forms: HashMap<&'static str, CurrencyForms>,
613}
614
615impl LangMr {
616 pub fn new() -> Self {
617 // Exactly the four codes Num2Word_MR declares, each a 2-tuple of
618 // 2-tuples. Note USD/EUR/GBP repeat the singular as the plural
619 // ("डॉलर"/"डॉलर", "पेन्स"/"पेन्स"); only INR actually inflects
620 // (रुपया -> रुपये, पैसा -> पैसे). The arity is load-bearing:
621 // `to_currency` indexes `cr1[1]`/`cr2[1]` for the plural.
622 let mut currency_forms = HashMap::new();
623 currency_forms.insert(
624 "INR",
625 CurrencyForms::new(&["रुपया", "रुपये"], &["पैसा", "पैसे"]),
626 );
627 currency_forms.insert(
628 "USD",
629 CurrencyForms::new(&["डॉलर", "डॉलर"], &["सेंट", "सेंट्स"]),
630 );
631 currency_forms.insert(
632 "EUR",
633 CurrencyForms::new(&["युरो", "युरो"], &["सेंट", "सेंट्स"]),
634 );
635 currency_forms.insert(
636 "GBP",
637 CurrencyForms::new(&["पाउंड", "पाउंड"], &["पेन्स", "पेन्स"]),
638 );
639 LangMr { currency_forms }
640 }
641}
642
643impl Default for LangMr {
644 fn default() -> Self {
645 Self::new()
646 }
647}
648
649impl Lang for LangMr {
650
651 fn cardinal_float_entry(
652 &self,
653 value: &crate::floatpath::FloatValue,
654 precision_override: Option<u32>,
655 ) -> crate::base::Result<String> {
656 // Python's to_cardinal routes every float/Decimal through this
657 // language's own decimal grammar — 5.0 keeps its ".0" tail
658 // ("comma nulla"), unlike Base's whole-value integer route.
659 self.to_cardinal_float(value, precision_override)
660 }
661
662 /// `to_ordinal(float/Decimal)`. Python's `to_ordinal` opens with ten
663 /// `number == k` checks, and `==` is numeric, so whole floats/Decimals
664 /// take the irregular words: `to_ordinal(1.0)` == "पहिला",
665 /// `to_ordinal(Decimal("5.00"))` == "पाचवा" — the fractional tail
666 /// vanishes entirely. Everything else falls through to
667 /// `to_cardinal(number) + "वा"`, i.e. the `str(number)` grammar with the
668 /// suffix glued on: 11.0 → "अकरा दशांश शून्यवा", 0.5 →
669 /// "शून्य दशांश पाचवा". -0.0 equals no special (and 0 has none anyway)
670 /// and keeps its textual negword: "ऋण शून्य दशांश शून्यवा"; -3.0 is
671 /// likewise "ऋण तीन दशांश शून्यवा" (oddity 5: no negative guard).
672 /// Exponent-form inputs (1e16, `Decimal("1E+2")`) miss every `==` and
673 /// then raise `int()`'s ValueError from inside `to_cardinal`.
674 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
675 if let Some(i) = value.as_whole_int() {
676 for (k, word) in ORDINAL_SPECIALS {
677 if i == bi(k) {
678 return Ok(word.to_string());
679 }
680 }
681 }
682 Ok(format!(
683 "{}{}",
684 self.to_cardinal_float(value, None)?,
685 ORDINAL_SUFFIX
686 ))
687 }
688
689 /// `to_ordinal_num(float/Decimal)`. Same numeric `==` on 1..=4, so 1.0 →
690 /// "१ला", 2.0 → "२रा", 3.0 → "३रा", `Decimal("4.00")` → "४था"; everything
691 /// else is `str(number) + "वा"` (oddity 6's numeral-system mix, now with
692 /// a decimal point in it). `repr_str` is the binding's Python
693 /// `str(value)`, exactly the string Python concatenates — `str()` never
694 /// raises, so 5.0 keeps its tail ("5.0वा") and exponent forms pass
695 /// through unharmed: "1e+16वा", "1E+2वा", "-0.0वा".
696 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
697 if let Some(i) = value.as_whole_int() {
698 if i == bi(1) {
699 return Ok("१ला".to_string());
700 }
701 if i == bi(2) {
702 return Ok("२रा".to_string());
703 }
704 if i == bi(3) {
705 return Ok("३रा".to_string());
706 }
707 if i == bi(4) {
708 return Ok("४था".to_string());
709 }
710 }
711 Ok(format!("{}{}", repr_str, ORDINAL_SUFFIX))
712 }
713
714 /// `to_year(float/Decimal)`. Python: `if val < 0:` — a *numeric* test,
715 /// not a sign-bit one, so -0.0 takes the "सन" arm and keeps the negword
716 /// its `to_cardinal` derives textually: "सन ऋण शून्य दशांश शून्य"
717 /// (`FloatValue::is_negative()` would lie here — it is sign-bit aware by
718 /// design). True negatives are `abs()`ed *before* `to_cardinal`, so the
719 /// BC arm never shows a negword: `to_year(-1.5)` ==
720 /// "इसवीसन पूर्व एक दशांश पाच". Exponent-form ValueErrors propagate from
721 /// `to_cardinal` in both arms.
722 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
723 let below_zero = match value {
724 // `-0.0 < 0` is False in Python.
725 FloatValue::Float { value, .. } => *value < 0.0,
726 // A BigInt mantissa carries no negative zero, so is_negative()
727 // is already the numeric test on this arm.
728 FloatValue::Decimal { value, .. } => value.is_negative(),
729 };
730 if below_zero {
731 let abs = match value {
732 FloatValue::Float { value, precision } => FloatValue::Float {
733 value: value.abs(),
734 precision: *precision,
735 },
736 FloatValue::Decimal { value, precision } => FloatValue::Decimal {
737 value: value.abs(),
738 precision: *precision,
739 },
740 };
741 Ok(format!(
742 "{}{}",
743 YEAR_BC_PREFIX,
744 self.to_cardinal_float(&abs, None)?
745 ))
746 } else {
747 Ok(format!(
748 "{}{}",
749 YEAR_PREFIX,
750 self.to_cardinal_float(value, None)?
751 ))
752 }
753 }
754
755 /// MR inherits Base's `str_to_number` (`Decimal(value)`), which parses
756 /// "Infinity"/"-Infinity" *successfully* — the ValueError Python shows
757 /// for `num2words("Infinity", lang="mr")` happens later, inside MR's
758 /// `to_cardinal` (`int("Infinity")` → `ValueError: invalid literal for
759 /// int() with base 10: 'Infinity'`; "-Infinity" strips its sign
760 /// textually first and reports 'Infinity' too). The Rust dispatcher
761 /// hard-codes Base's `int(Decimal("Infinity"))` semantics
762 /// (`OverflowError`) for `ParsedNumber::Inf`, which MR never executes,
763 /// so Inf parses punt to the Python fallback instead: it runs the
764 /// original converter and reproduces every mode byte for byte
765 /// (cardinal/ordinal/year raise the ValueError; `to_ordinal_num` happily
766 /// returns "Infinityवा"). NaN stays on the dispatcher path — its
767 /// hard-coded ValueError already matches the type MR raises from
768 /// `int("NaN")`. Same shape as `lang_be.rs` / `lang_as.rs`.
769 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
770 let parsed = python_decimal_parse(s)?;
771 Ok(parsed)
772 }
773
774 /// `Decimal('Infinity')` / `-Infinity` per mode. MR's `to_cardinal`
775 /// feeds the sign-stripped `str(number)` to `int()`, so `int("Infinity")`
776 /// raises `ValueError` for cardinal/ordinal/year ("-Infinity" strips its
777 /// sign textually first, so the message names 'Infinity' either way).
778 /// `to_ordinal_num` never parses — it is `str(number) + "वा"` — so it
779 /// succeeds: "Infinityवा" / "-Infinityवा". Replaces the base default
780 /// (`int(Decimal Inf)` → OverflowError), which MR never executes.
781 fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
782 match to {
783 "ordinal_num" => Ok(format!(
784 "{}Infinity{}",
785 if negative { "-" } else { "" },
786 ORDINAL_SUFFIX
787 )),
788 _ => Err(N2WError::Value(
789 "invalid literal for int() with base 10: 'Infinity'".to_string(),
790 )),
791 }
792 }
793
794 /// `Decimal('NaN')` per mode. cardinal/ordinal die in `int("NaN")` →
795 /// `ValueError`; `to_ordinal_num` returns "NaNवा" unparsed; but `to_year`
796 /// opens with `if val < 0:` — and `Decimal('NaN') < 0` raises
797 /// `decimal.InvalidOperation` *before* `to_cardinal` is ever reached.
798 fn nan_result(&self, to: &str) -> Result<String> {
799 match to {
800 "ordinal_num" => Ok(format!("NaN{}", ORDINAL_SUFFIX)),
801 "year" => Err(N2WError::Custom {
802 module: "decimal",
803 class: "InvalidOperation",
804 msg: "[<class 'decimal.InvalidOperation'>]".to_string(),
805 }),
806 _ => Err(N2WError::Value(
807 "invalid literal for int() with base 10: 'NaN'".to_string(),
808 )),
809 }
810 }
811
812 /// This language's own `to_currency(currency=...)` default,
813 /// read from the live Python signature. Only 44 of 156 use EUR.
814 fn default_currency(&self) -> &str {
815 "INR"
816 }
817
818 /// This language's own `to_currency(separator=...)` default,
819 /// read from the live Python signature. Base's is ",", but only
820 /// 36 of 149 languages actually use it — most default to " " or a
821 /// conjunction, so inheriting Base's comma silently corrupts them.
822 fn default_separator(&self) -> &str {
823 " आणि "
824 }
825
826 fn negword(&self) -> &str {
827 NEGWORD
828 }
829
830 fn pointword(&self) -> &str {
831 "दशांश"
832 }
833
834 /// Port of `Num2Word_MR.to_cardinal`, integer path only.
835 ///
836 /// Python does `n = str(number).strip()` and tests `n.startswith("-")`,
837 /// which for an integer is exactly a sign test. The `"." in n` branch
838 /// (float input) is out of scope: `str(int)` never contains a dot.
839 ///
840 /// The final `.strip()` is reproduced but cannot bite — `_int_to_word`
841 /// never returns an empty or space-padded string, so the only whitespace
842 /// in play is the single space inside `negword`.
843 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
844 let (ret, n) = if value.is_negative() {
845 (NEGWORD, value.abs())
846 } else {
847 ("", value.clone())
848 };
849 Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
850 }
851
852 /// Port of `Num2Word_MR.to_ordinal`.
853 ///
854 /// 1..=10 are irregular; everything else — including 0 and every
855 /// negative — is the cardinal with "वा" glued on. Never raises.
856 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
857 for (k, word) in ORDINAL_SPECIALS {
858 if value == &bi(k) {
859 return Ok(word.to_string());
860 }
861 }
862 Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
863 }
864
865 /// Port of `Num2Word_MR.to_ordinal_num`.
866 ///
867 /// Devanagari digits for 1..=4, ASCII `str(number)` for everything else
868 /// (including negatives: `to_ordinal_num(-1)` == "-1वा"). See oddity 6.
869 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
870 if value == &bi(1) {
871 return Ok("१ला".to_string());
872 }
873 if value == &bi(2) {
874 return Ok("२रा".to_string());
875 }
876 if value == &bi(3) {
877 return Ok("३रा".to_string());
878 }
879 if value == &bi(4) {
880 return Ok("४था".to_string());
881 }
882 Ok(format!("{}{}", value, ORDINAL_SUFFIX))
883 }
884
885 /// Port of `Num2Word_MR.to_year`. The `longval=True` parameter is
886 /// accepted and then ignored by Python, so the trait's arity matches.
887 fn to_year(&self, value: &BigInt) -> Result<String> {
888 if value.is_negative() {
889 Ok(format!(
890 "{}{}",
891 YEAR_BC_PREFIX,
892 self.to_cardinal(&value.abs())?
893 ))
894 } else {
895 Ok(format!("{}{}", YEAR_PREFIX, self.to_cardinal(value)?))
896 }
897 }
898
899 /// Port of `Num2Word_MR.to_cardinal`'s non-integer branch.
900 ///
901 /// MR does not use `Num2Word_Base.to_cardinal_float`; its overridden
902 /// `to_cardinal` reads `str(number)` and speaks the fractional digits one
903 /// at a time. So the raw f64 is turned back into `repr(float)` (or the
904 /// Decimal into `str(Decimal)`) and fed to [`cardinal_from_str`] verbatim.
905 /// The `precision=` kwarg (`precision_override`) is set on the converter by
906 /// `__init__.py` but `to_cardinal` never reads `self.precision`, so it is
907 /// ignored, exactly as Python ignores it.
908 ///
909 /// Python wraps both return arms in `.strip()`; it is a no-op on every
910 /// reachable output (the string always begins and ends with a word) but is
911 /// applied here for fidelity.
912 fn to_cardinal_float(
913 &self,
914 value: &FloatValue,
915 precision_override: Option<u32>,
916 ) -> Result<String> {
917 let _ = precision_override;
918 let n = match value {
919 // Python's str(float); the raw f64 crossed the boundary so repr()
920 // can be reproduced from the bits.
921 FloatValue::Float { value, .. } => py_float_repr(*value),
922 // Python's str(Decimal) — exact, never routed through f64.
923 FloatValue::Decimal { value, .. } => py_decimal_str(value),
924 };
925 Ok(cardinal_from_str(&n)?.trim().to_string())
926 }
927
928 // ---- currency ----------------------------------------------------
929
930 /// `self.__class__.__name__`, for the NotImplementedError message that
931 /// the inherited `to_cheque` raises on an unknown code.
932 fn lang_name(&self) -> &str {
933 "Num2Word_MR"
934 }
935
936 /// `CURRENCY_FORMS[code]`.
937 ///
938 /// **Only the inherited `to_cheque` reaches this.** MR's own
939 /// `to_currency` overrides the lookup with a `.get(currency, ...INR)`
940 /// fallback and never consults this hook — see [`LangMr::to_currency`].
941 /// `to_cheque` keeps the strict `self.CURRENCY_FORMS[currency]` subscript,
942 /// so `None` here reproduces the `KeyError` that Python catches and
943 /// re-raises as NotImplementedError.
944 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
945 self.currency_forms.get(code)
946 }
947
948 /// Port of `Num2Word_MR.to_currency`.
949 ///
950 /// MR replaces `Num2Word_Base.to_currency` wholesale with a string-slicing
951 /// implementation, so **none** of the base currency machinery participates:
952 /// no `parse_currency_parts`, no `pluralize`, no `_money_verbose` /
953 /// `_cents_verbose` / `_cents_terse`, no `CURRENCY_PRECISION`, and no
954 /// `adjective` support. Four consequences, all confirmed against the
955 /// corpus:
956 ///
957 /// 1. **Unknown codes never raise.** `CURRENCY_FORMS.get(currency,
958 /// CURRENCY_FORMS["INR"])` silently falls back to rupees, so
959 /// `to_currency(2, "CHF")` is "दोन रुपये" and JPY/KWD/BHD/CNY/CHF all
960 /// render in रुपये/पैसे. This is why the `mr` currency corpus has zero
961 /// NotImplementedError rows while the cheque corpus has five.
962 /// 2. **Precision is hardcoded to two decimal places** by the `[:2]`
963 /// slice, so the 3-decimal (KWD/BHD) and 0-decimal (JPY) currencies get
964 /// no special handling: `12.34 KWD` is "बारा रुपये आणि तीस चार पैसे",
965 /// not mils, and `12.34 JPY` still shows a cents segment.
966 /// 3. **`adjective` is accepted and then ignored** — MR declares the
967 /// parameter but never reads it, and defines no `CURRENCY_ADJECTIVES`.
968 /// 4. **Cents are dropped whenever `right == 0`**, including for floats.
969 /// `1.0` -> parts[1] is "0" -> `int("00")` -> 0 -> falsy -> "एक रुपया",
970 /// *not* "एक रुपया आणि शून्य पैसे" as `Num2Word_Base` would give. So
971 /// MR reaches the base's "int skips cents" outcome by a different
972 /// route: the `Int`/`Decimal` split still matters lexically (an int has
973 /// no "." at all, a float may carry non-zero cents), but for a
974 /// whole-valued float the two coincide.
975 ///
976 /// Python computes `parts = str(val).split(".")` *after* `val = abs(val)`,
977 /// so the sign is detached before stringification. `CurrencyValue` carries
978 /// the value parsed from that same `str(value)`, and `BigDecimal`'s
979 /// `Display` round-trips it (verified: "1.0" stays "1.0", "0.5" stays
980 /// "0.5" — the scale is preserved, so `int("00")` vs `int("50")` comes out
981 /// right). See the port report for the one lexical form where this
982 /// round-trip diverges.
983 fn to_currency(
984 &self,
985 val: &CurrencyValue,
986 currency: &str,
987 cents: bool,
988 separator: Option<&str>,
989 adjective: bool,
990 ) -> Result<String> {
991 // Trait now hands us None when the caller omitted separator=;
992 // resolve it to this language's own default before the ported body.
993 let separator = separator.unwrap_or(self.default_separator());
994 // Restore MR's own `separator=" आणि "` default; see SEPARATOR_UNSET.
995 let separator = if separator == SEPARATOR_UNSET {
996 SEPARATOR_DEFAULT
997 } else {
998 separator
999 };
1000 // Declared by Python and never read. Named for the reader's benefit.
1001 let _ = adjective;
1002
1003 // `if val < 0: is_negative = True; val = abs(val)`. BigDecimal's
1004 // is_negative() is false for "-0.0" (the sign is not carried on a zero
1005 // int_val), matching Python's `-0.0 < 0` being False.
1006 let is_negative = val.is_negative();
1007
1008 // `str(val)` — the value stringified *after* the abs().
1009 let s = match val {
1010 CurrencyValue::Int(v) => {
1011 if is_negative {
1012 v.abs().to_string()
1013 } else {
1014 v.to_string()
1015 }
1016 }
1017 CurrencyValue::Decimal { value: d, .. } => {
1018 if is_negative {
1019 d.abs().to_string()
1020 } else {
1021 d.to_string()
1022 }
1023 }
1024 };
1025
1026 // `parts = str(val).split(".")` — split on every dot, as Python does.
1027 let parts: Vec<&str> = s.split('.').collect();
1028
1029 // `left = int(parts[0]) if parts[0] else 0`. A non-numeric parts[0]
1030 // is a ValueError out of int(); str() of a float big enough to render
1031 // in exponent form ("1e+16") lands here and Python raises ValueError
1032 // too, so the variant matches rather than approximates.
1033 let left = if parts[0].is_empty() {
1034 BigInt::zero()
1035 } else {
1036 parts[0]
1037 .parse::<BigInt>()
1038 .map_err(|e| N2WError::Value(e.to_string()))?
1039 };
1040
1041 // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
1042 // — a *truncating* two-character slice, not a rounding quantize, so
1043 // 12.349 gives 34 and 12.999 gives 99 (never 100). Sliced by chars().
1044 let right = if parts.len() > 1 && !parts[1].is_empty() {
1045 let mut frac: String = parts[1].chars().take(2).collect();
1046 while frac.chars().count() < 2 {
1047 frac.push('0');
1048 }
1049 frac.parse::<BigInt>()
1050 .map_err(|e| N2WError::Value(e.to_string()))?
1051 } else {
1052 BigInt::zero()
1053 };
1054
1055 // `cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])`
1056 let forms = match self.currency_forms.get(currency) {
1057 Some(f) => f,
1058 None => self
1059 .currency_forms
1060 .get(CURRENCY_FALLBACK)
1061 .expect("INR is inserted by LangMr::new"),
1062 };
1063 let cr1 = &forms.unit;
1064 let cr2 = &forms.subunit;
1065
1066 let one = bi(1);
1067
1068 // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
1069 let left_str = int_to_word(&left);
1070 let mut result = format!(
1071 "{} {}",
1072 left_str,
1073 if left != one { &cr1[1] } else { &cr1[0] }
1074 );
1075
1076 // `if cents and right:` — `right` is falsy at 0, which is how a whole
1077 // float loses its cents segment.
1078 if cents && !right.is_zero() {
1079 let cents_str = int_to_word(&right);
1080 result.push_str(separator);
1081 result.push_str(¢s_str);
1082 result.push(' ');
1083 result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
1084 }
1085
1086 // `result = self.negword + result` — negword already carries its own
1087 // trailing space ("ऋण "), so this is the interior separator too.
1088 if is_negative {
1089 result = format!("{}{}", NEGWORD, result);
1090 }
1091
1092 // Python's `.strip()`. Nothing here is ever space-padded (negword's
1093 // space lands mid-string), so this cannot bite — reproduced anyway.
1094 Ok(result.trim().to_string())
1095 }
1096}