1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
//! String-input parsing: the Rust side of `converter.str_to_number` and the
//! `Decimal(value)` constructor semantics it relies on.
//!
//! Python's dispatcher hands string input to `str_to_number`, whose base
//! implementation is `Decimal(value)`. That constructor is *not*
//! `BigDecimal::from_str`: it strips whitespace, accepts a trailing dot
//! ("12."), PEP-515 underscores ("1_000"), any Unicode decimal digit
//! ("١٢٣"), and the special values Infinity/NaN. Reproducing its acceptance
//! set exactly is what keeps `num2words("12.")` and `num2words("abc")`
//! behaving identically to the Python original.
use crate::base::{N2WError, Result};
use bigdecimal::BigDecimal;
use num_bigint::BigInt;
use std::str::FromStr;
/// What `str_to_number` produced, including the per-language side effects
/// Python stashes on `self` for the same-call handshake.
#[derive(Debug, Clone)]
pub enum ParsedNumber {
/// Plain `Decimal(value)`.
Dec(BigDecimal),
/// `Decimal("Infinity")` — representable in Python, but `int()` on it
/// raises OverflowError, which the integer path does not catch.
Inf { negative: bool },
/// `Decimal("NaN")` — `int()` raises ValueError, which the base integer
/// path *does* catch, sending NaN into the float path (where it raises).
NaN,
/// lang_ES: "1ro" stashes `(n, gender)`; `to_cardinal` consumes it and
/// renders `to_ordinal(n, gender=...)` instead.
EsOrdinal { n: BigInt, gender: char },
/// lang_PT_BR: US-style "1.50" (dot, no comma) pronounces "ponto"
/// instead of "vírgula" for this call only.
DecPoint {
value: BigDecimal,
pointword: &'static str,
},
}
fn invalid() -> N2WError {
// decimal.InvalidOperation — a real class from the decimal module, so
// `except decimal.InvalidOperation` in caller code keeps working.
N2WError::Custom {
module: "decimal",
class: "InvalidOperation",
msg: "[<class 'decimal.ConversionSyntax'>]".into(),
}
}
/// The value of `ch` as a Unicode decimal digit, as Python's `int()` /
/// `Decimal()` accept (Nd category). Covers the blocks that show up in
/// practice; anything unlisted is rejected, which surfaces as
/// InvalidOperation exactly like a genuinely invalid character.
pub fn unicode_digit(ch: char) -> Option<u32> {
if ch.is_ascii_digit() {
return Some(ch as u32 - '0' as u32);
}
let c = ch as u32;
for &start in &[
0x0660, // Arabic-Indic
0x06F0, // Extended Arabic-Indic
0x0966, // Devanagari
0x09E6, // Bengali
0x0A66, // Gurmukhi
0x0AE6, // Gujarati
0x0B66, // Oriya
0x0BE6, // Tamil
0x0C66, // Telugu
0x0CE6, // Kannada
0x0D66, // Malayalam
0x0E50, // Thai
0x0ED0, // Lao
0x0F20, // Tibetan
0x1040, // Myanmar
0x17E0, // Khmer
0x1810, // Mongolian
0xFF10, // Fullwidth
] {
if (start..start + 10).contains(&c) {
return Some(c - start);
}
}
None
}
/// `any(ch.isdigit() for ch in s)` — the dispatcher's "does this string
/// contain digits" test that decides between the sentence fallback and
/// re-raising. str.isdigit() is wider than Nd (superscripts count), but the
/// decimal blocks above cover every case the library's callers hit.
pub fn has_py_digit(s: &str) -> bool {
s.chars()
.any(|c| unicode_digit(c).is_some() || c.is_numeric())
}
/// Python's `Decimal(str)` constructor.
///
/// Grammar (case-insensitive, surrounding whitespace stripped):
/// sign? ( digits '.' digits? | '.' digits | digits ) ('e' sign? digits)?
/// sign? ('inf' | 'infinity')
/// sign? ('nan' | 'snan') digits?
/// PEP 515: '_' allowed only between two digits.
pub fn python_decimal_parse(s: &str) -> Result<ParsedNumber> {
let t = s.trim();
if t.is_empty() {
return Err(invalid());
}
let lower = t.to_lowercase();
let (sign_neg, rest) = match lower.strip_prefix('-') {
Some(r) => (true, r),
None => (false, lower.strip_prefix('+').unwrap_or(&lower)),
};
if rest == "inf" || rest == "infinity" {
return Ok(ParsedNumber::Inf { negative: sign_neg });
}
if rest == "nan"
|| rest == "snan"
|| (rest.starts_with("nan")
&& rest[3..].chars().all(|c| c.is_ascii_digit())
&& !rest[3..].is_empty())
|| (rest.starts_with("snan")
&& rest[4..].chars().all(|c| c.is_ascii_digit())
&& !rest[4..].is_empty())
{
return Ok(ParsedNumber::NaN);
}
// Numeric form: normalise unicode digits to ASCII and validate the
// shape, then let BigDecimal parse the clean ASCII.
let src = t; // keep original case (irrelevant for digits) minus strip
let mut out = String::with_capacity(src.len());
let mut chars = src.chars().peekable();
if let Some(&c) = chars.peek() {
if c == '+' || c == '-' {
if c == '-' {
out.push('-');
}
chars.next();
}
}
let mut int_digits = 0usize;
let mut frac_digits = 0usize;
let mut seen_dot = false;
let mut exp_part = String::new();
let mut prev_was_digit = false;
while let Some(c) = chars.next() {
if let Some(d) = unicode_digit(c) {
out.push(char::from(b'0' + d as u8));
if seen_dot {
frac_digits += 1;
} else {
int_digits += 1;
}
prev_was_digit = true;
} else if c == '_' {
// PEP 515: must sit between two digits.
let next_is_digit = chars.peek().is_some_and(|&n| unicode_digit(n).is_some());
if !prev_was_digit || !next_is_digit {
return Err(invalid());
}
prev_was_digit = false;
} else if c == '.' {
if seen_dot {
return Err(invalid());
}
seen_dot = true;
out.push('.');
prev_was_digit = false;
} else if c == 'e' || c == 'E' {
// exponent: sign? digits (underscores allowed between digits)
exp_part.push('E');
if let Some(&sc) = chars.peek() {
if sc == '+' || sc == '-' {
exp_part.push(sc);
chars.next();
}
}
let mut exp_digits = 0usize;
let mut prev_exp_digit = false;
for ec in chars.by_ref() {
if let Some(d) = unicode_digit(ec) {
exp_part.push(char::from(b'0' + d as u8));
exp_digits += 1;
prev_exp_digit = true;
} else if ec == '_' {
if !prev_exp_digit {
return Err(invalid());
}
prev_exp_digit = false;
} else {
return Err(invalid());
}
}
if exp_digits == 0 || !prev_exp_digit {
return Err(invalid());
}
break;
} else {
return Err(invalid());
}
}
if int_digits == 0 && frac_digits == 0 {
return Err(invalid());
}
// "12." is valid; BigDecimal's parser rejects a trailing dot, so drop it.
if out.ends_with('.') {
out.pop();
}
// ".5" needs a leading zero for BigDecimal.
if out.starts_with('.') {
out.insert(0, '0');
} else if out.starts_with("-.") {
out.insert(1, '0');
}
out.push_str(&exp_part);
BigDecimal::from_str(&out)
.map(ParsedNumber::Dec)
.map_err(|_| invalid())
}
/// Python's `int(str)` — used by the "n/d" fraction-string branch. Accepts
/// surrounding whitespace, a sign, PEP-515 underscores and Unicode digits;
/// no dot, no exponent.
pub fn python_int_parse(s: &str) -> Option<BigInt> {
let t = s.trim();
if t.is_empty() {
return None;
}
let (neg, rest) = match t.strip_prefix('-') {
Some(r) => (true, r),
None => (false, t.strip_prefix('+').unwrap_or(t)),
};
if rest.is_empty() {
return None;
}
let mut ascii = String::with_capacity(rest.len());
let mut prev_was_digit = false;
let mut iter = rest.chars().peekable();
while let Some(c) = iter.next() {
if let Some(d) = unicode_digit(c) {
ascii.push(char::from(b'0' + d as u8));
prev_was_digit = true;
} else if c == '_' {
let next_is_digit = iter.peek().is_some_and(|&n| unicode_digit(n).is_some());
if !prev_was_digit || !next_is_digit {
return None;
}
prev_was_digit = false;
} else {
return None;
}
}
let mut v = BigInt::from_str(&ascii).ok()?;
if neg {
v = -v;
}
Some(v)
}
/// Python's `str(Decimal)` — the to-scientific-string algorithm from the
/// General Decimal Arithmetic spec. Needed because `has_decimal` checks
/// `"." in str(number)` and `to_ordinal_num`'s base default returns the
/// value itself, which the dispatcher then str()s.
pub fn python_decimal_str(d: &BigDecimal) -> String {
let (mant, scale) = d.as_bigint_and_exponent();
let exponent = -scale; // Python's as_tuple().exponent
let neg = mant.sign() == num_bigint::Sign::Minus;
let digits = mant.magnitude().to_string();
let ndigits = digits.len() as i64;
let adjusted = exponent + ndigits - 1;
let sign = if neg { "-" } else { "" };
if exponent <= 0 && adjusted >= -6 {
// Fixed-point notation.
if exponent == 0 {
return format!("{}{}", sign, digits);
}
let point = ndigits + exponent;
if point <= 0 {
return format!("{}0.{}{}", sign, "0".repeat((-point) as usize), digits);
}
let (i, f) = digits.split_at(point as usize);
return format!("{}{}.{}", sign, i, f);
}
// Scientific notation.
let exp = adjusted;
let mantissa = if digits.len() == 1 {
digits
} else {
format!("{}.{}", &digits[..1], &digits[1..])
};
format!(
"{}{}E{}{}",
sign,
mantissa,
if exp >= 0 { "+" } else { "" },
exp
)
}