Skip to main content

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}