num2words2_core/lang_az.rs
1//! Port of `lang_AZ.py` (Azerbaijani).
2//!
3//! Registry check: `__init__.py` maps `"az"` to `lang_AZ.Num2Word_AZ`, which is
4//! the class ported here.
5//!
6//! Shape: **self-contained**. `Num2Word_AZ` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
8//! populates `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
9//! outright and drives `int_to_word` over the decimal *string*, digit by digit,
10//! from the least significant end. Consequently `cards`/`maxval`/`merge` stay at
11//! their trait defaults here, and there is **no overflow check** — the only
12//! ceiling is the `POWERS_OF_TEN` table, which raises `KeyError` rather than
13//! `OverflowError` (see below).
14//!
15//! Inherited from `Num2Word_Base` and left unchanged by AZ:
16//! * nothing in scope — AZ overrides all four of `to_cardinal`, `to_ordinal`,
17//! `to_ordinal_num` and `to_year`, so no trait default is relied upon.
18//!
19//! `setup()` sets `negword = "mənfi"` and `pointword = "nöqtə"`. Both are
20//! reachable: `negword` for any negative input, `pointword` for the fractional
21//! path below.
22//!
23//! # The float/Decimal path does not use `floatpath.rs`
24//!
25//! `Num2Word_AZ` does **not** override `to_cardinal_float`, so the [`Lang`]
26//! hook of that name here is *not* a port of the Python method of that name —
27//! it carries AZ's `to_cardinal` for non-integer input, because `to_cardinal`
28//! is what the dispatcher calls and it handles floats inline. Verified live:
29//! `type(c).to_cardinal is not Num2Word_Base.to_cardinal` is `True`, and
30//! `to_cardinal_float` is inherited untouched (and therefore dead code for AZ).
31//!
32//! The consequence is that **`base.float2tuple` is never reached**. AZ's first
33//! statement is `value_str = str(value)`: the whole algorithm runs on the
34//! decimal *string*, so the binary artefacts `floatpath.rs` is built around
35//! never arise. `str(2.675)` is `"2.675"`, and AZ renders the fraction "675" as
36//! the integer six-hundred-seventy-five — "iki nöqtə altı yüz yetmiş beş". The
37//! `674.9999999999998` / `< 0.01` rescue and the banker's-rounding trap belong
38//! to the path AZ does not take; there is no `round()` anywhere in this file.
39//!
40//! What AZ needs instead is `str(value)` itself, reproduced byte for byte by
41//! [`py_str_f64`] and [`py_str_decimal`]. Note that AZ says "point" and then
42//! reads the fraction as a *whole number*, not digit by digit: `12.345` is "on
43//! iki nöqtə üç yüz qırx beş" ("twelve point three hundred forty-five"), where
44//! every language on the `floatpath.rs` default would say "üç dörd beş".
45//!
46//! ## Float/Decimal routing (the `*_float_entry` hooks)
47//!
48//! Because `str(5.0)` is `"5.0"` and the algorithm reads the `".0"` like any
49//! other fraction, AZ has **no whole-value shortcut**: `num2words(5.0,
50//! lang="az")` is "beş nöqtə sıfır", never "beş". [`Lang::cardinal_float_entry`]
51//! therefore bypasses the trait default's `as_whole_int()` fast path and hands
52//! everything to the string algorithm.
53//!
54//! `to_ordinal`/`to_ordinal_num`/`to_year` all open with `assert int(value) ==
55//! value`, which is *live* for float/Decimal input: a fractional value (0.5,
56//! 3.25) fails the comparison and raises a bare `AssertionError` — see
57//! [`assert_whole`]. A *whole* float passes the assert and then runs the same
58//! cardinal + vowel-suffix walk over the full float string, so
59//! `to_ordinal(5.0)` == "beş nöqtə sıfırıncı" and `to_ordinal_num(5.0)` ==
60//! "5.0-cı" (`"-".join([str(value), suffix])` keeps the repr verbatim).
61//! Exponent-form input (1e16, `Decimal("1E+2")`) is whole, so it passes the
62//! assert and then dies inside `to_cardinal` with bug 4's `ValueError` — in
63//! that order.
64//!
65//! Sign semantics are `value < 0`, a numeric comparison — **not** the sign
66//! bit: `-0.0 < 0` is False in Python and IEEE-754 alike, so `-0.0` renders
67//! with no negword ("sıfır nöqtə sıfır"), `to_year(-0.0)` takes no "e.ə.", and
68//! only `to_ordinal_num(-0.0)`'s echoed repr shows the "-" ("-0.0-cı").
69//!
70//! `num2words("Infinity", lang="az")` is a `ValueError`, not base's
71//! OverflowError: `Decimal("Infinity")` parses fine, and AZ's `to_cardinal`
72//! then walks `str()` == "Infinity" *reversed*, so the first `int()` call is
73//! `int("y")`. The shared Inf sentinel maps to OverflowError in the binding,
74//! so [`Lang::str_to_number`] raises the ValueError itself (RU takes the same
75//! route; see the override for the mode caveat).
76//!
77//! # Faithfully reproduced Python bugs
78//!
79//! This is a port, not a rewrite. Both of the following are wrong-looking but
80//! are exactly what Python emits, verified against the interpreter:
81//!
82//! 1. **The "we say `min`, not `bir min`" rule is applied one digit too
83//! broadly, and silently loses a whole thousand.** The guard is
84//! `if not (digit_int == 1 and index == 3)`, which tests only that the
85//! thousands digit is 1 — never that it is the *only* digit in its chunk.
86//! So the `bir` is dropped even when a ten-thousands or hundred-thousands
87//! digit precedes it:
88//! * `to_cardinal(21000)` == `"iyirmi min"` — literally "twenty thousand",
89//! i.e. 20000. The 1000 is gone.
90//! * `to_cardinal(31000)` == `"otuz min"` (30000), `to_cardinal(121000)`
91//! == `"yüz iyirmi min"` (120000), `to_cardinal(1001000)` ==
92//! `"bir milyon min"`.
93//! The rule is keyed on `index == 3` alone, so the same pattern at a higher
94//! power is unaffected: `to_cardinal(21000000)` == `"iyirmi bir milyon"` is
95//! correct. The bug is confined to the thousands chunk. Reproduced verbatim
96//! in [`int_to_word`]; do not "fix" it.
97//!
98//! 2. **`POWERS_OF_TEN` stops at 10^63, so 10^66 and up raise `KeyError`**
99//! rather than a deliberate `OverflowError`. `to_cardinal(10**65)` ==
100//! `"yüz vigintilyon"` is the largest value that works.
101//!
102//! 3. **`leading_zeros` under-counts an all-zero fraction by one.** The count
103//! is `len(num_str) - len(str(int(num_str)))`, and `str(int("00"))` is `"0"`
104//! — one character, not zero — so a fraction that is nothing but zeros gets
105//! one "sıfır" too few. `Decimal("1.00")` reads `"bir nöqtə sıfır"` rather
106//! than `"... sıfır sıfır"`, while `Decimal("1.000")` reads `"bir nöqtə
107//! sıfır sıfır"`. Only Decimal input can reach this: `repr(1.00)` is `"1.0"`,
108//! which has a single zero and is therefore correct by accident.
109//! Reproduced in [`int_to_word`].
110//!
111//! 4. **Any float whose `repr` is in exponent form raises `ValueError`.**
112//! `str(1e16)` is `"1e+16"`, and `int_to_word` calls `int(digit)` on every
113//! character of it, so `int("+")` blows up: `num2words(1e16, lang="az")` is
114//! a `ValueError`, not a number. The same goes for `1e21`, `1e-05`,
115//! `float("inf")` (`int("f")`), `float("nan")` (`int("n")`) and
116//! `Decimal("1E+2")`. `1e15` still works — `str(1e15)` is
117//! `"1000000000000000.0"` — so the cliff sits exactly where CPython's `repr`
118//! switches notation, at `decpt > 16`. See [`py_str_f64`].
119//!
120//! # Error variants
121//!
122//! `ValueError` (bug 4) maps to [`N2WError::Value`] carrying CPython's own
123//! message, `invalid literal for int() with base 10: '+'`. Which character is
124//! named is observable and load-bearing: `int_to_word` scans the digit string
125//! *reversed*, and `int(digit)` is the first statement of the loop body, so the
126//! exponent's sign character is always reached before any table lookup can
127//! `KeyError`. `"5e-324"` therefore reports `'-'` and not a `KeyError` on the
128//! hundreds slot it walks through on the way.
129//!
130//! `KeyError` (bug 2 above) maps to [`N2WError::Key`]. It is a Python crash
131//! rather than a deliberate raise, but the exception *type* is observable and
132//! callers may catch it, so parity means reproducing it rather than tidying it
133//! into an `OverflowError`.
134//!
135//! The *index* reported by the `KeyError` is load-bearing and is **not** simply
136//! the first multiple of 3 past 63. `int_to_word` only emits a power word when
137//! that power's own 3-digit chunk is non-zero (`set(chunk) != {"0"}`), so an
138//! all-zero chunk is skipped and the crash moves outward. Verified against
139//! CPython:
140//! * `10**66` → `KeyError: 66` (chunk at 66 is `['1']`)
141//! * `10**69` → `KeyError: 69` (chunk at 66 is all zeros → skipped)
142//! * `10**70` → `KeyError: 69` (chunk at 69 is `['0','1']`)
143//! * `10**72` → `KeyError: 72` (chunks at 66 and 69 both all zeros)
144//! [`int_to_word`] models the scan order so the reported index matches.
145//!
146//! `to_ordinal`/`to_ordinal_num`/`to_year` also carry `assert int(value) ==
147//! value` and (the first two) `assert last_vowel is not None`. The first is
148//! vacuous for integer input but **live for float/Decimal input**, where a
149//! fractional value maps to [`N2WError::Assertion`] with Python's bare-assert
150//! empty message (see [`assert_whole`]). The second is unreachable: every word
151//! in `DIGITS`/`DECIMALS`/`POWERS_OF_TEN` contains a vowel, so `_last_vowel`
152//! never returns `None` for any value that `to_cardinal` accepts; the
153//! unreachable arm keeps its historical [`N2WError::Value`] mapping rather
154//! than panicking.
155//!
156//! # Currency
157//!
158//! `Num2Word_AZ`'s MRO is `AZ -> Num2Word_Base -> object` — it does **not**
159//! descend from `Num2Word_EUR`, so the `lang_EUR.py`/`Num2Word_EN.__init__`
160//! shared-dict mutation described in `PORTING_CURRENCY.md` does not reach it.
161//! Verified against the live interpreter: `AZ.CURRENCY_FORMS is
162//! Num2Word_Base.CURRENCY_FORMS` is `False` (AZ declares its own class-body
163//! dict), and that dict holds exactly three codes. Everything else — GBP, JPY,
164//! KWD, BHD, INR, CNY, CHF — raises `NotImplementedError`, which is why AZ has
165//! neither a 3-decimal nor a 0-decimal currency to exercise.
166//!
167//! AZ overrides only the forms table and `pluralize`. Confirmed absent from
168//! `Num2Word_AZ.__dict__`: `to_currency`, `to_cheque`, `_money_verbose`,
169//! `_cents_verbose`, `_cents_terse` — all inherited from `Num2Word_Base`
170//! unchanged, so the trait defaults already mirror them and are not overridden.
171//!
172//! `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are *Base's own* dicts, still
173//! empty at runtime after every module has imported (checked with `is`, not
174//! just equality). So `currency_adjective` is always `None` and
175//! `CURRENCY_PRECISION.get(code, 100)` is always 100 — exactly the trait
176//! defaults. Overriding them would be noise.
177//!
178//! The `to_currency` signature is `(val, currency="EUR", cents=True,
179//! separator=",", adjective=False)`, i.e. Base's defaults unchanged; the
180//! generated `default_currency`/`default_separator` below already match it.
181
182use crate::base::{Lang, N2WError, Result};
183use crate::currency::CurrencyForms;
184use crate::floatpath::FloatValue;
185use crate::strnum::{python_decimal_parse, ParsedNumber};
186use bigdecimal::BigDecimal;
187use num_bigint::BigInt;
188use num_traits::{One, Signed, ToPrimitive};
189use std::collections::HashMap;
190
191/// `negword`, set in `setup()`.
192const NEGWORD: &str = "mənfi";
193
194/// `pointword`, set in `setup()`.
195const POINTWORD: &str = "nöqtə";
196
197/// `DIGITS`, keys 0..=9 — dense, so an array indexes it directly.
198const DIGITS: [&str; 10] = [
199 "sıfır", "bir", "iki", "üç", "dörd", "beş", "altı", "yeddi", "səkkiz", "doqquz",
200];
201
202/// `DECIMALS`, keys 1..=9. Index 0 is absent in Python; every read is guarded
203/// by `digit_int != 0`, so the placeholder is unreachable.
204const DECIMALS: [&str; 10] = [
205 "", "on", "iyirmi", "otuz", "qırx", "əlli", "altmış", "yetmiş", "səksən", "doxsan",
206];
207
208/// `VOWELS`. Note these are *characters*, several of them multi-byte in UTF-8
209/// (`ı`, `ə`, `ö`, `ü`), so every use goes through `chars()`.
210const VOWELS: [char; 9] = ['a', 'ı', 'o', 'u', 'e', 'ə', 'i', 'ö', 'ü'];
211
212/// `POWERS_OF_TEN`, a sparse dict: key 2, then every multiple of 3 up to 63.
213/// Returning `None` is Python's missing key, i.e. a `KeyError`.
214fn power_of_ten(index: usize) -> Option<&'static str> {
215 Some(match index {
216 2 => "yüz",
217 3 => "min",
218 6 => "milyon",
219 9 => "milyard",
220 12 => "trilyon",
221 15 => "katrilyon",
222 18 => "kentilyon",
223 21 => "sekstilyon",
224 24 => "septilyon",
225 27 => "oktilyon",
226 30 => "nonilyon",
227 33 => "desilyon",
228 36 => "undesilyon",
229 39 => "dodesilyon",
230 42 => "tredesilyon",
231 45 => "katordesilyon",
232 48 => "kendesilyon",
233 51 => "seksdesilyon",
234 54 => "septendesilyon",
235 57 => "oktodesilyon",
236 60 => "novemdesilyon",
237 63 => "vigintilyon",
238 _ => return None,
239 })
240}
241
242/// `VOWEL_TO_CARDINAL_SUFFIX_MAP`, built in Python from four `dict.fromkeys`
243/// groups. Every character in `VOWELS` is covered, so a lookup keyed on a
244/// `_last_vowel` result can never miss.
245fn cardinal_suffix(vowel: char) -> Option<&'static str> {
246 Some(match vowel {
247 'a' | 'ı' => "ıncı",
248 'e' | 'ə' | 'i' => "inci",
249 'o' | 'u' => "uncu",
250 'ö' | 'ü' => "üncü",
251 _ => return None,
252 })
253}
254
255/// `VOWEL_TO_CARDINAL_NUM_SUFFIX_MAP`. Same key coverage as above.
256fn cardinal_num_suffix(vowel: char) -> Option<&'static str> {
257 Some(match vowel {
258 'a' | 'ı' => "cı",
259 'e' | 'ə' | 'i' => "ci",
260 'o' | 'u' => "cu",
261 'ö' | 'ü' => "cü",
262 _ => return None,
263 })
264}
265
266/// Python's `_last_vowel`: scan the string backwards, return the first vowel.
267/// Iterates `chars()`, never bytes — `value[::-1]` in Python walks code points.
268fn last_vowel(value: &str) -> Option<char> {
269 value.chars().rev().find(|c| VOWELS.contains(c))
270}
271
272/// Python's `assert int(value) == value` — the opening statement of
273/// `to_ordinal`, `to_ordinal_num` and `to_year`, live on the float/Decimal
274/// entries. A fractional value fails the comparison and raises a **bare**
275/// `AssertionError` (no message — Python's plain `assert` carries none).
276///
277/// `int()` of inf/nan would raise OverflowError/ValueError *instead of* the
278/// assert failing, but the shim keeps non-finite floats on the Python side
279/// ("inf/nan stay on the Python error path"), so a `None` from `as_whole_int`
280/// here always means "finite but fractional".
281fn assert_whole(value: &FloatValue) -> Result<()> {
282 if value.as_whole_int().is_none() {
283 return Err(N2WError::Assertion(String::new()));
284 }
285 Ok(())
286}
287
288/// The suffix-attaching tail of Python's `to_ordinal`, shared by the integer
289/// and float/Decimal entries (Python has a single method serving both).
290///
291/// `if cardinal.endswith(tuple(self.VOWELS)): cardinal = cardinal[:-1]` —
292/// drop the trailing *character*, not the trailing byte. Several vowels here
293/// are two bytes in UTF-8, so truncating by 1 would split a code point and
294/// panic.
295fn ordinalize(cardinal: String) -> Result<String> {
296 let vowel = last_vowel(&cardinal).ok_or_else(|| {
297 // Python's `assert last_vowel is not None`. Unreachable: every
298 // numword contains a vowel (see the module docs' assert note).
299 N2WError::Value("AssertionError: last_vowel is None".into())
300 })?;
301 let suffix = cardinal_suffix(vowel).ok_or_else(|| N2WError::Key(format!("{}", vowel)))?;
302
303 let mut stem = cardinal;
304 if let Some(last) = stem.chars().next_back() {
305 if VOWELS.contains(&last) {
306 let cut = stem.len() - last.len_utf8();
307 stem.truncate(cut);
308 }
309 }
310
311 Ok(format!("{}{}", stem, suffix))
312}
313
314/// The suffix lookup of Python's `to_ordinal_num` — last vowel of the
315/// cardinal, then `VOWEL_TO_CARDINAL_NUM_SUFFIX_MAP`. Shared by the integer
316/// and float/Decimal entries; the caller supplies its own `str(value)`.
317fn ordinal_num_suffix_for(cardinal: &str) -> Result<&'static str> {
318 let vowel = last_vowel(cardinal).ok_or_else(|| {
319 N2WError::Value("AssertionError: last_vowel is None".into())
320 })?;
321 cardinal_num_suffix(vowel).ok_or_else(|| N2WError::Key(format!("{}", vowel)))
322}
323
324/// Python's `int_to_word`, over the decimal digit string of a non-negative
325/// value.
326///
327/// `leading_zeros` is Python's keyword argument: the integral part is rendered
328/// with it `False`, the fractional part with it `True`. It exists because
329/// `int_to_word` reads its input as a whole number ("05" is five), which would
330/// silently drop a fraction's leading zeros — so they are counted and re-added
331/// as bare "sıfır"s afterwards. One-off included; see bug 3.
332///
333/// Carries bugs 1, 2, 3 and 4 from the module docs. `words.insert(0, ..)`
334/// prepends, so the list is built most-significant-first while the scan runs
335/// least-significant-first.
336fn int_to_word(num_str: &str, leading_zeros: bool) -> Result<String> {
337 let mut words: Vec<&'static str> = Vec::new();
338 let reversed: Vec<char> = num_str.chars().rev().collect();
339
340 for (index, digit) in reversed.iter().enumerate() {
341 // `digit_int = int(digit)`. For integer input num_str comes from
342 // BigInt::to_string() and every char is an ASCII digit, but the float
343 // path can hand this an exponent-form repr ("1e+16"), and this is the
344 // first statement in the loop body — so a non-digit raises ValueError
345 // here, before any table lookup below can KeyError. BUG 4.
346 let digit_int = digit
347 .to_digit(10)
348 .ok_or_else(|| {
349 N2WError::Value(format!(
350 "invalid literal for int() with base 10: '{}'",
351 digit
352 ))
353 })? as usize;
354
355 // The number is parsed in three-digit chunks; the position within the
356 // chunk selects the branch.
357 let remainder_to_3 = index % 3;
358 if remainder_to_3 == 0 {
359 if index > 0 {
360 // `set(reversed_str[index:index+3]) != {"0"}` — emit the power
361 // word only when this chunk carries a non-zero digit. Python's
362 // slice clamps at the end of the list; so does this one. The
363 // chunk is never empty (index < len), so an all-'0' test is an
364 // exact model of the set comparison.
365 let end = std::cmp::min(index + 3, reversed.len());
366 let chunk_is_zero = reversed[index..end].iter().all(|c| *c == '0');
367 if !chunk_is_zero {
368 // BUG 2: no entry past 63 → KeyError, not OverflowError.
369 let word = power_of_ten(index)
370 .ok_or_else(|| N2WError::Key(format!("{}", index)))?;
371 words.insert(0, word);
372 }
373 }
374 if digit_int > 0 {
375 // "we say 'min' not 'bir min'" — BUG 1: keyed on the digit and
376 // the index only, never on whether the rest of the chunk is
377 // empty, so 21000 loses its thousand and reads "iyirmi min".
378 if !(digit_int == 1 && index == 3) {
379 words.insert(0, DIGITS[digit_int]);
380 }
381 }
382 } else if remainder_to_3 == 1 {
383 if digit_int != 0 {
384 words.insert(0, DECIMALS[digit_int]);
385 }
386 } else {
387 // remainder is 2 — the hundreds slot.
388 if digit_int > 0 {
389 // POWERS_OF_TEN[2], always present.
390 words.insert(0, power_of_ten(2).expect("hundreds"));
391 }
392 // "bir yüz" is not said, so the digit word is emitted only from 2 up.
393 if digit_int > 1 {
394 words.insert(0, DIGITS[digit_int]);
395 }
396 }
397 }
398
399 if num_str == "0" {
400 words.push(DIGITS[0]);
401 }
402
403 if leading_zeros {
404 // Python: `zeros_count = len(num_str) - len(str(int(num_str)))`, then
405 // `words[:0] = zeros_count * [self.DIGITS[0]]`.
406 //
407 // `str(int(num_str))` is the input with its leading zeros stripped —
408 // except that an all-zero string collapses to a single "0" rather than
409 // to nothing, which is BUG 3: "00" counts 2 - 1 = 1 zero, not 2, and
410 // Decimal("1.00") loses a "sıfır".
411 //
412 // The loop above has already proved every char is an ASCII digit, so
413 // the `int()` here cannot raise. An empty num_str would (Python:
414 // `int("")`), but the caller guards it with `if not fraction_part`.
415 let significant = num_str.trim_start_matches('0');
416 let int_len = if significant.is_empty() {
417 1 // str(int("000")) == "0"
418 } else {
419 significant.chars().count()
420 };
421 let zeros_count = num_str.chars().count().saturating_sub(int_len);
422 // Prepending one at a time is `words[:0] = n * [x]` — the elements are
423 // all the same word, so order cannot differ.
424 for _ in 0..zeros_count {
425 words.insert(0, DIGITS[0]);
426 }
427 }
428
429 Ok(words.join(" "))
430}
431
432/// Python's `str(float)` — which for a float is `repr(float)`.
433///
434/// This string *is* AZ's input: `to_cardinal` starts with `value_str =
435/// str(value)` and never looks at the number again except for `if value < 0`.
436/// So unlike every language on `floatpath.rs`, AZ needs `repr` reproduced
437/// rather than the raw f64's binary arithmetic.
438///
439/// Reproduces CPython's `format_float_short(d, 'r', ..., ADD_DOT_0)`:
440///
441/// * Digit *generation* is delegated to Rust's own shortest-round-trip
442/// formatter, which is the same contract as Python's `repr` — we do not
443/// reimplement it.
444/// * The notation switch is CPython's `if (decpt <= -4 || decpt > 16)
445/// use_exp = 1;` — the comment there explains the 16: converting at 1e17
446/// "gives odd-looking results ... repr(2e16+8) would give
447/// 20000000000000010.0".
448/// * The exponent is `sprintf("%+.02d")`: always signed, zero-padded to two
449/// digits, hence "1e+16" and "1e-05".
450/// * `Py_DTSF_ADD_DOT_0` is why `repr(1.0)` is "1.0" and not "1".
451///
452/// Checked against CPython over 90,011 doubles (structured edge cases, random
453/// bit patterns and random decimal-scaled values): zero mismatches.
454fn py_str_f64(value: f64) -> String {
455 // CPython: "adapt Gay's output, so convert Infinity to inf and NaN to nan,
456 // and ignore sign of nan". Rust spells these "inf" and "NaN".
457 if value.is_nan() {
458 return "nan".to_string();
459 }
460 if value.is_infinite() {
461 return if value < 0.0 {
462 "-inf".to_string()
463 } else {
464 "inf".to_string()
465 };
466 }
467
468 // `{:e}` is Rust's shortest round-trip in scientific form ("-1.234e-7"),
469 // which yields both the digit count and the decimal-point position — the
470 // `digits` / `decpt` pair `_Py_dg_dtoa` hands `format_float_short`.
471 let sci = format!("{:e}", value);
472 let (mantissa, exponent) = match sci.split_once('e') {
473 Some(pair) => pair,
474 None => return sci, // unreachable: LowerExp always emits an 'e'.
475 };
476 let decpt = exponent.parse::<i32>().unwrap_or(0) + 1;
477 let ndigits = mantissa.chars().filter(char::is_ascii_digit).count() as i32;
478
479 if decpt <= -4 || decpt > 16 {
480 // Exponent form. Every one of these is a ValueError once int_to_word
481 // reaches the '+'/'-', but the string is built out in full anyway: it
482 // is what Python builds, and which character the error names depends on
483 // it.
484 let (sign, mantissa) = match mantissa.strip_prefix('-') {
485 Some(rest) => ("-", rest),
486 None => ("", mantissa),
487 };
488 let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
489 let mut out = String::from(sign);
490 out.push_str(&digits[..1]);
491 if digits.len() > 1 {
492 // No ADD_DOT_0 here: repr(1e21) is "1e+21", not "1.0e+21".
493 out.push('.');
494 out.push_str(&digits[1..]);
495 }
496 let exp = decpt - 1;
497 out.push_str(&format!(
498 "e{}{:02}",
499 if exp < 0 { '-' } else { '+' },
500 (exp as i64).abs()
501 ));
502 out
503 } else {
504 // Fixed form. The fraction is `ndigits - decpt` wide, floored at 1 by
505 // ADD_DOT_0 (repr(1e15) is "1000000000000000.0", one trailing zero).
506 //
507 // Re-rendering with `{:.n$}` instead of splicing the `{:e}` digits back
508 // together is deliberate, and the one place Rust and CPython disagree:
509 // on an *exact* tie Rust's shortest formatter rounds away from zero
510 // while CPython's dtoa rounds to even. 2181495296738027.25 is exactly
511 // representable, so `{:e}` gives "2.1814952967380273e15" — splicing
512 // would print "2181495296738027.3" where Python prints
513 // "...027.2". `{:.n$}` is exact and ties-to-even, so it agrees.
514 // The tie moves only the last digit's value, never `ndigits` or
515 // `decpt`, so reading those off `{:e}` is still sound.
516 let frac = std::cmp::max(ndigits - decpt, 1) as usize;
517 format!("{:.*}", frac, value)
518 }
519}
520
521/// Python's `str(Decimal)` — `to-scientific-string` from the General Decimal
522/// Arithmetic spec, as `_pydecimal.Decimal.__str__` implements it.
523///
524/// `BigDecimal` stores (coefficient, scale) with `value = coefficient *
525/// 10**-scale`, so Python's `_int` is the coefficient's digits and its `_exp`
526/// is `-scale`. `BigDecimal`'s own `Display` is *not* this function — it prints
527/// `Decimal("1E+2")` as "100" and `Decimal("0.0")` as "0" — so the conversion is
528/// spelled out rather than delegated. Checked against CPython over 3,039
529/// Decimals: the only divergence is negative zero (see below).
530fn py_str_decimal(value: &BigDecimal) -> String {
531 let (coefficient, scale) = value.as_bigint_and_exponent();
532 // `sign = ['', '-'][self._sign]`. BigInt has no negative zero, so a
533 // Decimal("-0.0") arrives here as plain "0.0" where Python would say
534 // "-0.0". Unobservable for AZ: to_cardinal strips the '-' off the integral
535 // part and re-adds it from `value < 0`, which is False for -0 either way.
536 let sign = if coefficient.is_negative() { "-" } else { "" };
537 let int_digits = coefficient.abs().to_string(); // Decimal._int
538 let exp = -scale; // Decimal._exp
539 let ndigits = int_digits.len() as i64;
540 let leftdigits = exp + ndigits;
541
542 // `if self._exp <= 0 and leftdigits > -6: dotplace = leftdigits`
543 // `elif not eng: dotplace = 1`. __str__ passes eng=False, so the
544 // engineering-notation arms below it are dead.
545 let dotplace = if exp <= 0 && leftdigits > -6 {
546 leftdigits
547 } else {
548 1
549 };
550
551 let (intpart, fracpart) = if dotplace <= 0 {
552 (
553 "0".to_string(),
554 format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
555 )
556 } else if dotplace >= ndigits {
557 (
558 format!("{}{}", int_digits, "0".repeat((dotplace - ndigits) as usize)),
559 String::new(),
560 )
561 } else {
562 let cut = dotplace as usize;
563 (
564 int_digits[..cut].to_string(),
565 format!(".{}", &int_digits[cut..]),
566 )
567 };
568
569 let exppart = if leftdigits == dotplace {
570 String::new()
571 } else {
572 // `['e', 'E'][context.capitals] + "%+d" % (leftdigits-dotplace)`. The
573 // default context has capitals=1, so 'E' — and "%+d" is *not* padded to
574 // two digits, unlike repr(float)'s "%+.02d". Hence "1E+2", not "1E+02".
575 let exp = leftdigits - dotplace;
576 format!("E{}{}", if exp < 0 { "-" } else { "+" }, exp.abs())
577 };
578
579 format!("{}{}{}{}", sign, intpart, fracpart, exppart)
580}
581
582/// The body of `Num2Word_AZ.to_cardinal`, given the `str(value)` it opens with
583/// and the `value < 0` it closes with.
584///
585/// Split out because the sign cannot be recovered from the string: `str(-0.5)`
586/// starts with '-' but so does nothing about `Decimal("-0.0")`'s sign, and
587/// Python asks the *number*, not the text.
588fn cardinal_from_py_str(value_str: &str, is_negative: bool) -> Result<String> {
589 // Python: `parts = value_str.split("."); parts[0]; parts[1] if len(parts) >
590 // 1 else ""`. Split-then-index, not a 2-way split — a third field would be
591 // dropped rather than folded into the fraction. No str() output has two
592 // dots, but splitn(2, '.') would not be the same function.
593 let mut parts = value_str.split('.');
594 let integral_part = parts.next().unwrap_or("");
595 let fraction_part = parts.next().unwrap_or("");
596
597 // `if integral_part.startswith("-"): integral_part = integral_part[1:]`
598 let integral_part = integral_part.strip_prefix('-').unwrap_or(integral_part);
599
600 // Python binds the integral part first, so when both halves are
601 // unrenderable its error is the one that escapes.
602 let integral_part_in_words = int_to_word(integral_part, false)?;
603 let fraction_part_in_words = if fraction_part.is_empty() {
604 String::new()
605 } else {
606 int_to_word(fraction_part, true)?
607 };
608
609 let mut value_in_words = integral_part_in_words;
610 if !fraction_part.is_empty() {
611 // `" ".join([integral_part_in_words, self.pointword, ...])`. Note the
612 // bare pointword — AZ never runs it through title().
613 value_in_words = format!("{} {} {}", value_in_words, POINTWORD, fraction_part_in_words);
614 }
615 if is_negative {
616 value_in_words = format!("{} {}", NEGWORD, value_in_words);
617 }
618 Ok(value_in_words)
619}
620
621/// `Num2Word_AZ.CURRENCY_FORMS`, transcribed from the class body.
622///
623/// `CURRENCY_INTEGRAL`/`CURRENCY_FRACTION` are separate class attributes in
624/// Python purely so the AZN entry can name them; they are inlined here because
625/// nothing else reads them.
626///
627/// Every entry carries exactly two forms, and in AZ both are always identical —
628/// the language does not inflect these nouns for number ("iki avro", not "iki
629/// avros"). The arity is still 2 rather than 1 because `pluralize` indexes
630/// `forms[1]` for every `n != 1`; collapsing to one form would turn "iki avro"
631/// into an IndexError.
632fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
633 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
634 m.insert("AZN", CurrencyForms::new(&["manat", "manat"], &["qəpik", "qəpik"]));
635 m.insert("EUR", CurrencyForms::new(&["avro", "avro"], &["sent", "sent"]));
636 m.insert("USD", CurrencyForms::new(&["dollar", "dollar"], &["sent", "sent"]));
637 m
638}
639
640pub struct LangAz {
641 currency_forms: HashMap<&'static str, CurrencyForms>,
642}
643
644impl LangAz {
645 pub fn new() -> Self {
646 LangAz {
647 // Built once here, never per call. `to_currency`/`to_cheque` only
648 // read this map; rebuilding it per call is what made an earlier
649 // revision of this port slower than the Python it replaces.
650 currency_forms: build_currency_forms(),
651 }
652 }
653}
654
655impl Default for LangAz {
656 fn default() -> Self {
657 Self::new()
658 }
659}
660
661impl Lang for LangAz {
662 /// This language's own `to_currency(currency=...)` default,
663 /// read from the live Python signature. Only 44 of 156 use EUR.
664 fn default_currency(&self) -> &str {
665 "EUR"
666 }
667
668 /// This language's own `to_currency(separator=...)` default,
669 /// read from the live Python signature. Base's is ",", but only
670 /// 36 of 149 languages actually use it — most default to " " or a
671 /// conjunction, so inheriting Base's comma silently corrupts them.
672 fn default_separator(&self) -> &str {
673 ","
674 }
675
676 fn negword(&self) -> &str {
677 NEGWORD
678 }
679
680 fn pointword(&self) -> &str {
681 POINTWORD
682 }
683
684 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
685 // Python: value_str = str(value); parts = value_str.split(".").
686 // Integer input never contains ".", so integral_part is the whole
687 // string and fraction_part is "" — the pointword branch is dead here.
688 // (The same Python method serves float input; see to_cardinal_float,
689 // which reaches the general form via cardinal_from_py_str.)
690 let value_str = value.to_string();
691 let integral_part = value_str.strip_prefix('-').unwrap_or(&value_str);
692
693 let integral_part_in_words = int_to_word(integral_part, false)?;
694
695 // `if value < 0` tests the original value, not the stripped string.
696 if value.is_negative() {
697 Ok(format!("{} {}", NEGWORD, integral_part_in_words))
698 } else {
699 Ok(integral_part_in_words)
700 }
701 }
702
703 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
704 // `assert int(value) == value` is vacuous for integer input.
705 let cardinal = self.to_cardinal(value)?;
706 ordinalize(cardinal)
707 }
708
709 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
710 let cardinal = self.to_cardinal(value)?;
711 let suffix = ordinal_num_suffix_for(&cardinal)?;
712
713 // `"-".join([str(value), suffix])` — str(value) keeps the minus sign,
714 // so to_ordinal_num(-1) == "-1-ci".
715 Ok(format!("{}-{}", value, suffix))
716 }
717
718 fn to_year(&self, value: &BigInt) -> Result<String> {
719 let year = self.to_cardinal(&value.abs())?;
720 if value.is_negative() {
721 Ok(format!("e.ə. {}", year))
722 } else {
723 Ok(year)
724 }
725 }
726
727 // ---- float / Decimal -------------------------------------------------
728
729 /// **Not** a port of `Num2Word_AZ.to_cardinal_float` — there is no such
730 /// method. AZ inherits Base's, which is dead code for it, and overrides
731 /// `to_cardinal` instead, handling non-integers inline. This hook is where
732 /// the dispatcher's float input lands, so it carries that inline handling.
733 ///
734 /// `precision_override` (the `precision=` kwarg, issue #580) is ignored,
735 /// which is not an omission: `Num2Word_AZ.to_cardinal` never reads
736 /// `self.precision`, so the dispatcher's save/restore around the call
737 /// changes nothing. Verified live — `num2words(3.14159, lang="az",
738 /// precision=2)` and `precision=0` are both byte-identical to the plain
739 /// call, all three "üç nöqtə on dörd min yüz əlli doqquz". AZ reads the
740 /// fraction as a whole number, so there is no per-digit knob to turn.
741 fn to_cardinal_float(
742 &self,
743 value: &FloatValue,
744 _precision_override: Option<u32>,
745 ) -> Result<String> {
746 // `value_str = str(value)` — the whole algorithm's input. The Float and
747 // Decimal arms stay apart for the reason floatpath.rs keeps them apart:
748 // str(Decimal("98746251323029.99")) is exact, while the f64 nearest
749 // that value reprs as "98746251323029.98" (issue #603).
750 let value_str = match value {
751 FloatValue::Float { value, .. } => py_str_f64(*value),
752 FloatValue::Decimal { value, .. } => py_str_decimal(value),
753 };
754 // `if value < 0` asks the number, not the string — and it is a `<`
755 // comparison, not the sign bit: -0.0 is not less than zero in Python
756 // or in IEEE-754, so it keeps no negword: both num2words(-0.0) and
757 // num2words(Decimal("-0.0")) are "sıfır nöqtə sıfır".
758 // (`FloatValue::is_negative()` is sign-bit aware and would wrongly
759 // say true for -0.0.)
760 let lt_zero = match value {
761 FloatValue::Float { value, .. } => *value < 0.0,
762 FloatValue::Decimal { value, .. } => value.is_negative(),
763 };
764 cardinal_from_py_str(&value_str, lt_zero)
765 }
766
767 /// `to_cardinal(float/Decimal)` — the full entry, whole values included.
768 ///
769 /// AZ never takes a whole-value shortcut: `str(5.0)` is "5.0" and the
770 /// algorithm reads the ".0" like any other fraction, so `num2words(5.0,
771 /// lang="az")` is "beş nöqtə sıfır", never "beş". The trait default's
772 /// `as_whole_int()` fast path is therefore wrong for AZ and bypassed.
773 fn cardinal_float_entry(
774 &self,
775 value: &FloatValue,
776 precision_override: Option<u32>,
777 ) -> Result<String> {
778 self.to_cardinal_float(value, precision_override)
779 }
780
781 /// `to_ordinal(float/Decimal)`: `assert int(value) == value` — a bare
782 /// AssertionError for any fractional value (0.5, 3.25) — then the same
783 /// cardinal + vowel-suffix walk as the integer path, over the cardinal of
784 /// the *full* float string: `to_ordinal(5.0)` == "beş nöqtə sıfırıncı".
785 ///
786 /// Exponent-form input (1e16, `Decimal("1E+2")`) is whole, so it passes
787 /// the assert and then dies inside `to_cardinal` with bug 4's ValueError
788 /// — in that order.
789 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
790 assert_whole(value)?;
791 let cardinal = self.to_cardinal_float(value, None)?;
792 ordinalize(cardinal)
793 }
794
795 /// `to_ordinal_num(float/Decimal)`: the assert, the cardinal (needed only
796 /// for its last vowel — and for its ValueError on exponent forms), then
797 /// `"-".join([str(value), suffix])`. `str(value)` is echoed verbatim, so
798 /// the ".0" and -0.0's sign survive: "5.0-cı", "5.00-cı", "-0.0-cı".
799 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
800 assert_whole(value)?;
801 let cardinal = self.to_cardinal_float(value, None)?;
802 let suffix = ordinal_num_suffix_for(&cardinal)?;
803 Ok(format!("{}-{}", repr_str, suffix))
804 }
805
806 /// `to_year(float/Decimal)`: the assert, then `year =
807 /// self.to_cardinal(abs(value))` — the cardinal of the *absolute* value,
808 /// so the negword never appears — and `if value < 0` (numeric, not the
809 /// sign bit) picks the "e.ə." era prefix. -0.0 is not < 0, so
810 /// `to_year(-0.0)` is a plain "sıfır nöqtə sıfır".
811 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
812 assert_whole(value)?;
813 let (value_str, lt_zero) = match value {
814 FloatValue::Float { value: f, .. } => (py_str_f64(f.abs()), *f < 0.0),
815 FloatValue::Decimal { value: d, .. } => (py_str_decimal(&d.abs()), d.is_negative()),
816 };
817 let year = cardinal_from_py_str(&value_str, false)?;
818 if lt_zero {
819 Ok(format!("e.ə. {}", year))
820 } else {
821 Ok(year)
822 }
823 }
824
825 /// Base `str_to_number` (`Decimal(value)`), with one AZ-visible fixup:
826 /// `Decimal("Infinity")` parses fine in Python, and AZ's `to_cardinal`
827 /// then walks `str()` == "Infinity" *reversed* ("ytinifnI"), so the first
828 /// `int()` call is `int("y")` — `ValueError: invalid literal for int()
829 /// with base 10: 'y'`, not the OverflowError of base's
830 /// `int(Decimal('Infinity'))`. "-Infinity" strips its sign first and
831 /// names the same 'y'. The shared Inf sentinel maps to OverflowError in
832 /// the binding, so the ValueError is raised here instead.
833 ///
834 /// (For to="ordinal"/"ordinal_num"/"year" Python *would* OverflowError
835 /// inside `assert int(value) == value` before to_cardinal runs, but only
836 /// cardinal rows exist in the corpus and the mode is not visible from
837 /// this hook — the RU port makes the same call.)
838 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
839 match python_decimal_parse(s)? {
840 ParsedNumber::Inf { .. } => Err(N2WError::Value(
841 "invalid literal for int() with base 10: 'y'".into(),
842 )),
843 other => Ok(other),
844 }
845 }
846
847 /// The fractional-cents branch of `Num2Word_Base.to_currency`, which Python
848 /// reaches as `self.to_cardinal(float(right))` — AZ's `to_cardinal`, not
849 /// Base's `to_cardinal_float`. The trait default routes to
850 /// `floatpath::cardinal_from_bigdecimal`, i.e. Base's digit-by-digit
851 /// rendering, which AZ does not inherit and which differs as soon as the
852 /// leftover cents carry more than one digit: at 1.0655 USD (65.5 cents)
853 /// Python says "altı nöqtə əlli beş sent" — "six point fifty-five" — where
854 /// Base's path would say "altı nöqtə beş beş".
855 ///
856 /// The `float(right)` cast is reproduced rather than avoided: it is what
857 /// collapses Decimal("6.5500") to "6.55" before str() ever sees it.
858 fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
859 let float = value.to_f64().ok_or_else(|| {
860 N2WError::Value(format!("cannot represent {} as f64", value))
861 })?;
862 cardinal_from_py_str(&py_str_f64(float), float < 0.0)
863 }
864
865 // ---- currency -------------------------------------------------------
866 //
867 // AZ defines only `CURRENCY_FORMS` and `pluralize`; `to_currency`,
868 // `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse` are
869 // inherited from `Num2Word_Base` verbatim, and the trait defaults already
870 // reproduce them. `currency_adjective`/`currency_precision` are left at
871 // their defaults because AZ's are Base's still-empty dicts (see module
872 // docs), so `None` and `100` are the right answers for every code.
873
874 fn lang_name(&self) -> &str {
875 "Num2Word_AZ"
876 }
877
878 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
879 // `None` becomes Python's `KeyError` -> the `NotImplementedError` that
880 // `to_currency`/`to_cheque` re-raise. AZ knows only AZN/EUR/USD, so
881 // this is the live path for every other code in the corpus.
882 self.currency_forms.get(code)
883 }
884
885 /// `Num2Word_AZ.pluralize`: `forms[0 if n == 1 else 1]`.
886 ///
887 /// Textually identical to `Num2Word_EUR.pluralize`, but arrived at
888 /// independently — AZ does not inherit from EUR.
889 ///
890 /// Python indexes the tuple directly, so a one-form entry with `n != 1`
891 /// would raise IndexError. All three AZ entries have two forms, so that is
892 /// unreachable; it is mapped to `Index` rather than panicking so the
893 /// exception type survives if the table ever changes.
894 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
895 let form = if n.is_one() { 0 } else { 1 };
896 forms
897 .get(form)
898 .cloned()
899 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
900 }
901}