Skip to main content

num2words2_core/
lang_en_aero.rs

1//! Port of `lang_EN_AERO.py` — ICAO/aviation English (`en_AERO`).
2//!
3//! Registry check: `CONVERTER_CLASSES["en_AERO"]` →
4//! `lang_EN_AERO.Num2Word_EN_AERO()` (the ICAO base class, `PROFILE = "ICAO"`).
5//! The module also defines `Num2Word_EN_AERO_FAA` / `_USN` / `_US_Army` /
6//! `_NATO` subclasses, but those are registered under *other* keys
7//! (`en_Aero_FAA`, …) and are out of scope for this file. All five profiles
8//! share `_ICAO_DIGITS`/`_ICAO_DECIMAL`/`_ICAO_MINUS` today anyway, so they
9//! are byte-identical in the four modes we implement — see `_PROFILES`.
10//!
11//! Shape: **self-contained**. `Num2Word_EN_AERO` subclasses `Num2Word_EN`
12//! (and therefore inherits its cards/merge/MAXVAL through `lang_EUR` →
13//! `Num2Word_Base`), but it overrides `to_cardinal` outright with a
14//! digit-by-digit renderer that never consults `self.cards`. So `cards`,
15//! `maxval` and `merge` stay at their trait defaults here: nothing in the
16//! four in-scope modes can reach them.
17//!
18//! # The `_english` delegate
19//!
20//! Python's `__init__` builds a *sibling* plain-English converter
21//! (`self._english = Num2Word_EN()`) and routes `to_ordinal`, `to_fraction`,
22//! `to_currency` and `to_cheque` through it. This is deliberate, and the
23//! docstring says why: the digit-by-digit cardinal must not leak into the
24//! ordinal builder, or `Num2Word_EN.to_ordinal(3)` would read the last
25//! cardinal word "tree" and emit "treeth" instead of "third". The same goes
26//! for fractions: `to_fraction(22, 7)` is "twenty-two sevenths", never
27//! "too too sevenths".
28//!
29//! [`LangEnAero`] mirrors that literally by owning a [`LangEn`]. Consequences
30//! worth stating, because they look like inconsistencies but are the spec:
31//!
32//! * `to_cardinal(3)` == "tree" but `to_ordinal(3)` == "third" — the two
33//!   modes genuinely use different converters.
34//! * `to_ordinal` inherits `Num2Word_EN`'s overflow ceiling (MAXVAL) and its
35//!   "and" joiner (`to_ordinal(101)` == "one hundred and first"), while
36//!   `to_cardinal` has **no** ceiling at all: it only stringifies digits, so
37//!   it happily renders values far above `Num2Word_EN.MAXVAL`.
38//! * `to_currency(12.34, "USD")` == "twelve dollars, thirty-four cents", not
39//!   "wun too dollars…". Money is *not* read digit-by-digit. The corpus
40//!   confirms this for all 108 currency and 9 cheque rows.
41//!
42//! # Currency: pure delegation, and why no table is built here
43//!
44//! Python is a two-liner — `to_currency`/`to_cheque` are `(*args, **kwargs)`
45//! pass-throughs to `self._english`. So *every* currency hook resolves on the
46//! delegate: `CURRENCY_FORMS`, `CURRENCY_ADJECTIVES`, `CURRENCY_PRECISION`,
47//! `pluralize`, `_money_verbose`, `_cents_verbose`, `_cents_terse` and the
48//! `to_cardinal` they call. None of `Num2Word_EN_AERO`'s own inherited copies
49//! are ever consulted, even though the instance carries them.
50//!
51//! [`LangEnAero`] mirrors that by forwarding both methods to the [`LangEn`] it
52//! already owns, and overrides **nothing else**. Consequences:
53//!
54//! * No `CURRENCY_FORMS` table is built in this file — the one that matters is
55//!   `LangEn`'s, constructed once in `LangEn::new()`, which `LangEnAero::new()`
56//!   already calls once and stores. Adding a second table here would be dead
57//!   weight that could silently drift from the delegate's.
58//! * `lang_name()` is deliberately left at its default: it is unreachable from
59//!   this file, because the `NotImplementedError` is raised inside the
60//!   delegate's `to_currency`, where `self.__class__.__name__` is the
61//!   *delegate's* name. Python therefore reports `Num2Word_EN`, **not**
62//!   `Num2Word_EN_AERO`, for an unknown code — verified against CPython:
63//!   `to_currency(12.34, "XXX")` raises
64//!   `Currency code "XXX" not implemented for "Num2Word_EN"`.
65//!   Forwarding reproduces that quirk for free; a locally-built table would
66//!   have emitted the wrong class name.
67//!
68//! # Precision quirk: JPY is a 2-decimal currency here
69//!
70//! `Num2Word_EN.__init__` *rebinds* `CURRENCY_PRECISION` to
71//! `{BHD, KWD, OMR, JOD, TND, LYD, IQD: 1000}`. JPY/KRW are **absent**, so
72//! they fall through to the default 100 and keep their historical sen/jeon
73//! subunits (the Python comment says this is intentional, to satisfy the test
74//! fixtures). Hence `to_currency(12.34, "JPY")` == "twelve yen, thirty-four
75//! sen" — the zero-decimal (`divisor == 1`) branch in
76//! `currency::default_to_currency` is **unreachable** for every code this
77//! language defines.
78//!
79//! # Overrides that differ from the `Num2Word_EN` parent
80//!
81//! * `to_year(value)` ignores `Num2Word_EN`'s century logic entirely and is
82//!   just `to_cardinal(value)`. Aviation reads years digit-by-digit, so
83//!   `to_year(1971)` == "wun niner seven wun", **not** "nineteen seventy-one".
84//!   Negatives are *not* special-cased into a "BC" suffix the way the parent
85//!   does: the raw value reaches `_digits_of`, which strips the sign into the
86//!   leading "minus" word. Hence `to_year(-44)` == "minus fower fower"
87//!   (parent EN would say "forty-four BC"). Verified against the corpus.
88//! * `to_ordinal_num(value)` is `verify_ordinal(value); str(int(value))` — it
89//!   drops the parent's `"%s%s" % (value, self.to_ordinal(value)[-2:])`
90//!   suffix logic. So it returns bare digits: `to_ordinal_num(1)` == "1",
91//!   not "1st". This coincides with the trait's default `to_ordinal_num`
92//!   *except* for the `verify_ordinal` guard, which the default lacks, so the
93//!   override below exists purely to reproduce the negative-input TypeError.
94//!
95//! # Digit table
96//!
97//! `_ICAO_DIGITS` respells six digits to survive radio static. Kept verbatim,
98//! including the non-obvious spellings, which are correct ICAO and **not**
99//! typos: 1 → "wun", 2 → "too", 3 → "tree", 4 → "fower", 5 → "fife",
100//! 8 → "ait", 9 → "niner". 0/6/7 are unchanged ("zero"/"six"/"seven").
101//!
102//! # Errors
103//!
104//! `verify_ordinal` (from `Num2Word_Base`) raises `TypeError` on negatives,
105//! reached by both `to_ordinal` (via the `_english` delegate's own
106//! `verify_ordinal`) and `to_ordinal_num` (via `self`'s). No `KeyError` is
107//! reachable in these modes: `to_cardinal`'s `self._digit_table[ch]` lookup is
108//! guarded by `ch.isdigit()`, and `BigInt`'s decimal rendering only ever
109//! produces ASCII `0`-`9` plus a leading `-` that `_digits_of` strips first.
110
111use crate::base::{Lang, N2WError, Result};
112use crate::currency::CurrencyValue;
113use crate::floatpath::FloatValue;
114use crate::lang_en::LangEn;
115use crate::strnum::{python_decimal_parse, ParsedNumber};
116use bigdecimal::BigDecimal;
117use num_bigint::{BigInt, Sign};
118use num_traits::Signed;
119
120/// Python's `_ICAO_DIGITS`, indexed by digit value rather than by char.
121///
122/// The Python original is a `dict` keyed by the *character* `"0"`..`"9"`;
123/// since every key is a distinct decimal digit, a positional table is the
124/// same mapping with the same total-ness.
125const ICAO_DIGITS: [&str; 10] = [
126    "zero", "wun", "too", "tree", "fower", "fife", "six", "seven", "ait", "niner",
127];
128
129/// Python's `_ICAO_DECIMAL`.
130const ICAO_DECIMAL: &str = "decimal";
131
132/// Python's `_ICAO_MINUS`.
133const ICAO_MINUS: &str = "minus";
134
135pub struct LangEnAero {
136    /// Mirrors `self._english = Num2Word_EN()` — the sibling converter that
137    /// `to_ordinal` delegates to so digit-by-digit cardinals never leak in.
138    english: LangEn,
139    /// Mirrors `self._digit_table`, from `_PROFILES["ICAO"]`.
140    digit_table: [&'static str; 10],
141    /// Mirrors `self._decimal_word`. Unreachable from integer input (kept so
142    /// `to_cardinal` stays a line-for-line image of the Python).
143    decimal_word: &'static str,
144    /// Mirrors `self._minus_word`.
145    minus_word: &'static str,
146}
147
148impl Default for LangEnAero {
149    fn default() -> Self {
150        Self::new()
151    }
152}
153
154impl LangEnAero {
155    pub fn new() -> Self {
156        // Python resolves `profile or self.PROFILE` → "ICAO" for this key, then
157        // unpacks `_PROFILES["ICAO"]`. The ValueError for an unknown profile is
158        // unreachable here: the registry constructs this class with no argument.
159        LangEnAero {
160            english: LangEn::new(),
161            digit_table: ICAO_DIGITS,
162            decimal_word: ICAO_DECIMAL,
163            minus_word: ICAO_MINUS,
164        }
165    }
166
167    /// Port of `_digits_of` for the integer inputs in scope.
168    ///
169    /// Python normalises to `(sign, int-part, frac-part)` and handles `str` /
170    /// `Decimal` / everything-else. Our input is always a `BigInt`, i.e. the
171    /// `else: s = str(value)` branch. The comma/underscore stripping and the
172    /// `"." in s` split are then both no-ops (`BigInt::to_string` emits no
173    /// separators and no point), as is the `if not int_part: int_part = "0"`
174    /// guard, since `str(0)` is already `"0"`. So the frac part is always
175    /// empty and only the sign split survives.
176    fn digits_of(&self, value: &BigInt) -> (bool, String) {
177        let s = value.to_string();
178        let is_negative = s.starts_with('-');
179        let int_part = if is_negative { s[1..].to_string() } else { s };
180        (is_negative, int_part)
181    }
182
183    /// Port of `_digits_of` for the non-integer inputs the float path carries.
184    ///
185    /// Python's `to_cardinal` is overridden outright and reads the value's
186    /// *string* form digit-by-digit — `str(value)` for a `float`,
187    /// `format(value, "f")` for a `Decimal` — and never touches `float2tuple`
188    /// or `to_cardinal_float`. This reproduces `_digits_of`'s
189    /// `(sign, int-part, frac-part)` split from that string content:
190    ///
191    /// * **Float** — [`python_float_repr`] reproduces Python's `str(value)`
192    ///   digit-for-digit, **including the scientific form**: in the normal
193    ///   range both are the correctly-rounded `p`-digit decimal of the *same*
194    ///   IEEE-754 double, and outside `[1e-4, 1e16)` CPython's repr switches
195    ///   to `"1e+16"` / `"1e-05"`, which AERO then reads lexically ('e'/'+'
196    ///   are skipped by the digit filter) — `1e16` is "wun wun six", not the
197    ///   sixteen-zero expansion. This is precisely why the f64-artefact
198    ///   heuristic in `base.float2tuple` is irrelevant on this route: `2.675`
199    ///   reads as its repr digits "six seven fife", not the `674.999…`-rescued
200    ///   `675`. `is_sign_negative()` mirrors `str(value).startswith("-")` (so
201    ///   `-0.0` carries the leading "minus" word, as in Python — corpus:
202    ///   `cardinal(-0.0)` → "minus zero decimal zero"). A sci-form mantissa
203    ///   with a fraction splits on its '.' exactly as `_digits_of` does
204    ///   (`"1.5e+20"` → int "1", frac "5e+20" → "wun decimal fife too zero").
205    /// * **Decimal** — rebuild `format(value, "f")` from the absolute unscaled
206    ///   digits at scale `precision`, which preserves trailing zeros exactly
207    ///   (`Decimal("1.10")` -> int "1", frac "10"). `precision` equals the
208    ///   Decimal's own scale (`abs(as_tuple().exponent)`), so forcing that
209    ///   scale is a faithful no-op that also guards against any normalisation.
210    ///
211    /// The `if not int_part: int_part = "0"` guard is a no-op here (both arms
212    /// always emit at least one integer digit) but is reproduced for parity.
213    fn digits_of_float(&self, v: &FloatValue) -> (bool, String, String) {
214        match v {
215            FloatValue::Float { value, precision } => {
216                let is_negative = value.is_sign_negative();
217                let signed = python_float_repr(*value, *precision);
218                let s = signed.strip_prefix('-').unwrap_or(&signed).to_string();
219                let (int_part, frac_part) = match s.split_once('.') {
220                    Some((i, f)) => (i.to_string(), f.to_string()),
221                    None => (s, String::new()),
222                };
223                let int_part = if int_part.is_empty() {
224                    "0".to_string()
225                } else {
226                    int_part
227                };
228                (is_negative, int_part, frac_part)
229            }
230            FloatValue::Decimal { value, .. } => {
231                let is_negative = value.is_negative();
232                // format(value, "f"): fixed-point, using the Decimal's *own*
233                // exponent, trailing zeros kept. `as_bigint_and_exponent`
234                // returns (unscaled, scale) with value == unscaled * 10^-scale,
235                // so a NEGATIVE scale here is a positive Decimal exponent —
236                // `Decimal("1E+2")` -> (1, -2) — which format "f" expands to
237                // plain integer digits with NO fractional part ("100", never
238                // "100.00"). The `precision` field can't express that case
239                // (it's the |scale|), so the sign is read off the value itself.
240                let (unscaled, scale) = value.abs().as_bigint_and_exponent();
241                if scale <= 0 {
242                    // format(Decimal("1E+20"), "f") == "1" + 20 * "0": integer
243                    // digits only, so the "decimal" word never appears.
244                    let digits =
245                        (unscaled * BigInt::from(10).pow((-scale) as u32)).to_string();
246                    return (is_negative, digits, String::new());
247                }
248                let scale = scale as usize;
249                let digits = unscaled.to_string();
250                let (int_part, frac_part) = {
251                    // Ensure at least one integer digit before the point, as
252                    // format(value, "f") renders "0.01" not ".01".
253                    let padded = if digits.len() < scale + 1 {
254                        format!("{}{}", "0".repeat(scale + 1 - digits.len()), digits)
255                    } else {
256                        digits
257                    };
258                    let split = padded.len() - scale;
259                    (padded[..split].to_string(), padded[split..].to_string())
260                };
261                let int_part = if int_part.is_empty() {
262                    "0".to_string()
263                } else {
264                    int_part
265                };
266                (is_negative, int_part, frac_part)
267            }
268        }
269    }
270
271    /// Port of `Num2Word_Base.verify_ordinal`.
272    ///
273    /// The float check (`not value == int(value)` → TypeError) cannot fire on
274    /// `BigInt`; only the negative check is reachable. Message text matches
275    /// `errmsg_negord` byte for byte.
276    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
277        if value.sign() == Sign::Minus {
278            return Err(N2WError::Type(format!(
279                "Cannot treat negative num {} as ordinal.",
280                value
281            )));
282        }
283        Ok(())
284    }
285}
286
287impl Lang for LangEnAero {
288
289    fn python_maxval(&self) -> Option<num_bigint::BigInt> {
290        // Python class attribute MAXVAL (self-contained converter).
291        Some(num_bigint::BigInt::from(10u32).pow(306))
292    }
293    // cards() / maxval() / merge() stay at their trait defaults: to_cardinal is
294    // overridden below and never drives splitnum/clean, so nothing reaches them.
295    // (Python still *has* Num2Word_EN's cards on this instance — they are simply
296    // dead weight for every mode in scope.)
297
298    fn negword(&self) -> &str {
299        // Inherited from Num2Word_EN's setup(). Unused: the AERO to_cardinal
300        // emits self._minus_word ("minus", no trailing space) instead, and
301        // default_to_cardinal is never reached.
302        "minus "
303    }
304
305    fn pointword(&self) -> &str {
306        "point"
307    }
308
309    /// Port of `Num2Word_EN_AERO.to_cardinal` — digit-by-digit, never composite.
310    ///
311    /// `5739` → "fife seven tree niner", not "five thousand seven hundred
312    /// thirty-nine". The `isdigit()` guard and the fractional branch are kept
313    /// even though integer input makes them total/dead respectively, so this
314    /// reads against the Python line for line.
315    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
316        let (is_negative, int_part) = self.digits_of(value);
317        let mut words: Vec<&str> = Vec::new();
318        if is_negative {
319            words.push(self.minus_word);
320        }
321        for ch in int_part.chars() {
322            if let Some(d) = ch.to_digit(10) {
323                words.push(self.digit_table[d as usize]);
324            }
325        }
326        // The `if frac_part:` arm of the Python is unreachable for BigInt input
327        // (digits_of always returns an empty fractional part), so self.decimal_word
328        // is never emitted here. Touched to keep the field live and documented.
329        let _ = self.decimal_word;
330        Ok(words.join(" "))
331    }
332
333    /// Port of the float/Decimal path of `Num2Word_EN_AERO.to_cardinal`.
334    ///
335    /// AERO does **not** use `Num2Word_Base.to_cardinal_float`/`float2tuple`:
336    /// its overridden `to_cardinal` handles a non-integer `value` inline by
337    /// reading `_digits_of(value)` (the value's string form) digit-by-digit.
338    /// So this override reproduces that body rather than delegating to
339    /// `floatpath::default_to_cardinal_float`, which would (a) use `pointword`
340    /// ("point") instead of the ICAO `_decimal_word` ("decimal") and (b) route
341    /// through the `< 0.01` f64-artefact heuristic that AERO never touches.
342    ///
343    /// `precision_override` (issue #580) is deliberately ignored: Python's
344    /// `AERO.to_cardinal` takes no `precision` kwarg and never consults
345    /// `self.precision`, so setting it has no effect on this mode's output.
346    fn to_cardinal_float(
347        &self,
348        value: &FloatValue,
349        _precision_override: Option<u32>,
350    ) -> Result<String> {
351        let (is_negative, int_part, frac_part) = self.digits_of_float(value);
352        let mut words: Vec<&str> = Vec::new();
353        if is_negative {
354            words.push(self.minus_word);
355        }
356        for ch in int_part.chars() {
357            if let Some(d) = ch.to_digit(10) {
358                words.push(self.digit_table[d as usize]);
359            }
360        }
361        if !frac_part.is_empty() {
362            words.push(self.decimal_word);
363            for ch in frac_part.chars() {
364                if let Some(d) = ch.to_digit(10) {
365                    words.push(self.digit_table[d as usize]);
366                }
367            }
368        }
369        Ok(words.join(" "))
370    }
371
372    /// Port of `to_ordinal` — delegates to the plain-English sibling.
373    ///
374    /// Deliberate: ICAO does not standardise ordinals, and routing through the
375    /// AERO cardinal would produce "treeth" for 3. The delegate runs its own
376    /// `verify_ordinal`, which is where the negative-input TypeError comes from.
377    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
378        self.english.to_ordinal(value)
379    }
380
381    /// Port of `to_ordinal_num`: `verify_ordinal(value); return str(int(value))`.
382    ///
383    /// Note this deliberately does **not** call `to_ordinal` — no "st"/"nd"/"th"
384    /// suffix is appended, unlike the Num2Word_EN parent it overrides.
385    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
386        self.verify_ordinal(value)?;
387        Ok(value.to_string())
388    }
389
390    /// Port of `to_year`: aviation reads years digit-by-digit, so this is just
391    /// `to_cardinal`. No century logic, no "BC" suffix — see the module docs.
392    fn to_year(&self, value: &BigInt) -> Result<String> {
393        self.to_cardinal(value)
394    }
395
396    // ---- float/Decimal entries ----------------------------------------
397    //
398    // Python's dispatcher hands floats/Decimals straight to the converter
399    // methods, so AERO's `to_cardinal` string-reading, the `_english`
400    // delegation of `to_ordinal`, and `to_ordinal_num`'s `verify_ordinal`
401    // all become reachable with fractional and *whole* non-int input alike.
402
403    /// `to_cardinal(float/Decimal)` — the FULL entry, whole values included.
404    ///
405    /// AERO's `to_cardinal` reads `str(value)` / `format(value, "f")` and
406    /// therefore keeps the ".0" tail Python's string carries: `5.0` →
407    /// "fife decimal zero", **never** the trait default's whole-value integer
408    /// route ("fife"). The `Decimal("Infinity")`/`Decimal("NaN")` sentinels
409    /// (see [`aero_str_to_number`]) render exactly as `_digits_of` reads
410    /// `format(value, "f")` = "Infinity"/"-Infinity"/"NaN": no digit chars,
411    /// so only the sign word survives — `""` / `"minus"` / `""` (corpus).
412    fn cardinal_float_entry(
413        &self,
414        value: &FloatValue,
415        precision_override: Option<u32>,
416    ) -> Result<String> {
417        if let Some(sp) = aero_special_of(value) {
418            return Ok(sp.cardinal_words().to_string());
419        }
420        self.to_cardinal_float(value, precision_override)
421    }
422
423    /// `to_ordinal(float/Decimal)` → `self._english.to_ordinal(value)`.
424    ///
425    /// The delegate's `verify_ordinal` does the type policing: whole values
426    /// ordinalise in plain English (`5.0` → "fifth", `Decimal("1E+2")` →
427    /// "one hundredth", `-0.0` → "zeroth"), fractional or negative values
428    /// raise TypeError. The Infinity/NaN sentinels reproduce the `int(value)`
429    /// raise *inside* that comparison: OverflowError / ValueError.
430    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
431        if let Some(sp) = aero_special_of(value) {
432            return Err(sp.int_error());
433        }
434        self.english.ordinal_float_entry(value)
435    }
436
437    /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)` — AERO's own,
438    /// i.e. `Num2Word_Base`'s — then `str(int(value))`.
439    ///
440    /// Bare truncated digits, no English suffix: `5.00` → "5", `1e+16` →
441    /// "10000000000000000", `-0.0` → "0" (both checks pass numerically).
442    /// Python's check order is float-ness first, then sign, so `-1.5` raises
443    /// the *float* message. `%s` interpolates `str(value)` = `repr_str`.
444    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
445        if let Some(sp) = aero_special_of(value) {
446            return Err(sp.int_error());
447        }
448        match value.as_whole_int() {
449            None => Err(N2WError::Type(format!(
450                "Cannot treat float {} as ordinal.",
451                repr_str
452            ))),
453            Some(i) => {
454                if i.sign() == Sign::Minus {
455                    Err(N2WError::Type(format!(
456                        "Cannot treat negative num {} as ordinal.",
457                        repr_str
458                    )))
459                } else {
460                    Ok(i.to_string())
461                }
462            }
463        }
464    }
465
466    /// `to_year(float/Decimal)` = `to_cardinal(value)` — the same digit-by-
467    /// digit string reading, ".0" tail and all: `to_year(1971.0)` →
468    /// "wun niner seven wun decimal zero".
469    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
470        self.cardinal_float_entry(value, None)
471    }
472
473    /// Port of `to_fraction`: `return self._english.to_fraction(numerator,
474    /// denominator)` — standard English forms ("one half", "three quarters",
475    /// "twenty-two sevenths"), never digit-by-digit. Both the cardinal
476    /// numerator and the ordinal denominator are the *delegate's*, so `22/7`
477    /// is "twenty-two sevenths", not "too too sevenths". The ZeroDivisionError
478    /// for `n/0` also originates inside the delegate.
479    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
480        self.english.to_fraction(numerator, denominator)
481    }
482
483    /// `converter.str_to_number` — base `Decimal(value)` semantics, with
484    /// `Decimal("Infinity")`/`Decimal("NaN")` carried across the boundary as
485    /// sentinels instead of the dispatcher's hardwired base errors, because
486    /// AERO's string-reading `to_cardinal` *succeeds* on them (→ ""/"minus").
487    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
488        aero_str_to_number(s)
489    }
490
491    // ---- currency ----------------------------------------------------
492    //
493    // Python:
494    //     def to_currency(self, *args, **kwargs):
495    //         return self._english.to_currency(*args, **kwargs)
496    //     def to_cheque(self, *args, **kwargs):
497    //         return self._english.to_cheque(*args, **kwargs)
498    //
499    // Whole-method forwarding, so no other currency hook is overridden here —
500    // see the module docs for why that is the faithful choice rather than a
501    // shortcut (it is what makes the unknown-code error name the *delegate*,
502    // and what keeps the forms table single-sourced in `LangEn::new()`).
503
504    /// Port of `Num2Word_EN_AERO.to_currency` — forwards to the `_english`
505    /// sibling, so money reads as ordinary English, never digit-by-digit.
506    ///
507    /// The forwarded call lands in `Num2Word_Base.to_currency` with
508    /// `self` bound to the delegate, which is exactly what
509    /// `Lang::to_currency`'s default does for `LangEn`. In particular the
510    /// `CurrencyValue::Int` / `Decimal` split is preserved end to end: `100`
511    /// renders "one hundred dollars" (no cents segment), while `1.0` renders
512    /// "one dollar, zero cents".
513    fn to_currency(
514        &self,
515        val: &CurrencyValue,
516        currency: &str,
517        cents: bool,
518        separator: Option<&str>,
519        adjective: bool,
520    ) -> Result<String> {
521        // Keep the Infinity/NaN sentinels out of the delegate's arithmetic
522        // (no corpus row reaches this; Python's delegate raises from the
523        // int() cast inside parse_currency_parts, same types as here).
524        if let CurrencyValue::Decimal { value: d, .. } = val {
525            if let Some(sp) = aero_special_of_decimal(d) {
526                return Err(sp.int_error());
527            }
528        }
529        self.english
530            .to_currency(val, currency, cents, separator, adjective)
531    }
532
533    /// Port of `Num2Word_EN_AERO.to_cheque` — forwards to the `_english`
534    /// sibling. `1234.56` / KWD → "ONE THOUSAND, TWO HUNDRED AND THIRTY-FOUR
535    /// AND 560/1000 DINARS" (3-digit subunit, plural unit, upper-cased).
536    fn to_cheque(&self, val: &BigDecimal, currency: &str) -> Result<String> {
537        self.english.to_cheque(val, currency)
538    }
539}
540
541// ---------------------------------------------------------------------
542// Shared AERO helpers.
543//
544// Python defines Num2Word_EN_AERO and all four service subclasses in ONE
545// module (`lang_EN_AERO.py`); the subclasses add nothing but `PROFILE`.
546// The pieces below are that shared behaviour, `pub(crate)` so the four
547// profile files (`lang_en_aero_faa/nato/us_army/usn.rs`) reuse them
548// instead of drifting apart — exactly the sharing the Python source has.
549// ---------------------------------------------------------------------
550
551/// `str(value)` for a Python `float` — the shortest round-trip repr.
552///
553/// In the normal range the value formats to exactly `precision` fractional
554/// digits, where `precision` is the repr-derived count handed across the
555/// boundary (`abs(Decimal(repr(v)).as_tuple().exponent)`), so `{:.precision}`
556/// reproduces `repr(float)` digit for digit. Outside `[1e-4, 1e16)` CPython
557/// switches to scientific (`"1e+16"`, `"1e-05"`, `"1.5e-07"`); the thresholds
558/// `a >= 1e16 || a < 1e-4` are exactly CPython's `decpt > 16 || decpt <= -4`.
559/// Rust's `{:e}` is shortest round-trip like Python's repr, so the mantissa
560/// digits agree; only the exponent presentation (mandatory sign, two-digit
561/// minimum) is reshaped to match. AERO's `_digits_of` then reads whichever
562/// form character by character — which is why `1e16` is "wun wun six".
563///
564/// `nan`/`inf` cannot reach this through the boundary (the Python-side
565/// precision step raises TypeError first), but are spelled Python's lowercase
566/// way regardless so a digit reader collapses them to `""` as `_digits_of`
567/// would.
568pub(crate) fn python_float_repr(v: f64, precision: u32) -> String {
569    if v.is_nan() {
570        return "nan".to_string();
571    }
572    if v.is_infinite() {
573        return if v < 0.0 { "-inf" } else { "inf" }.to_string();
574    }
575    let a = v.abs();
576    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
577        // "-1.234e-5" -> "-1.234e-05"; "1e16" -> "1e+16".
578        let sci = format!("{:e}", v);
579        let (mantissa, exp) = sci
580            .split_once('e')
581            .expect("Rust's {:e} always emits an 'e' separator");
582        let exp: i32 = exp
583            .parse()
584            .expect("Rust's {:e} always emits a decimal exponent");
585        return format!(
586            "{}e{}{:02}",
587            mantissa,
588            if exp < 0 { '-' } else { '+' },
589            exp.abs()
590        );
591    }
592    format!("{:.*}", precision as usize, v)
593}
594
595// ---- Decimal("Infinity") / Decimal("NaN") sentinels -------------------
596//
597// Python: `num2words("Infinity", lang="en_AERO")` parses to
598// `Decimal("Infinity")`, and AERO's `to_cardinal` *succeeds* on it —
599// `_digits_of` reads `format(value, "f")` = "Infinity", finds no digit
600// characters, and returns just the sign word: "" / "minus" / "" for
601// Infinity / -Infinity / NaN (all three are corpus rows).
602//
603// The Rust dispatcher, however, hardwires `ParsedNumber::Inf`/`NaN` to
604// Base's OverflowError/ValueError *before* any per-language hook — correct
605// for every converter whose to_cardinal casts to int, wrong for AERO. So
606// AERO's `str_to_number` smuggles the specials through as `ParsedNumber::
607// Dec` sentinels the float entries un-smuggle. The sentinel is a 1-digit
608// coefficient at an exponent (~1e9) no real corpus value can carry (Python
609// would need a literal `1E+1000000001`), and every AERO entry checks for it
610// before any arithmetic, so the huge magnitude is never materialised.
611
612/// Sentinel exponent for `Decimal("Infinity")` (`-1` coefficient: negative).
613pub(crate) const AERO_INF_EXPONENT: i64 = 1_000_000_001;
614/// Sentinel exponent for `Decimal("NaN")`.
615pub(crate) const AERO_NAN_EXPONENT: i64 = 1_000_000_003;
616
617/// Which special a sentinel stands for.
618pub(crate) enum AeroSpecial {
619    Inf { negative: bool },
620    NaN,
621}
622
623impl AeroSpecial {
624    /// What AERO's `to_cardinal` says for this special: `_digits_of` finds no
625    /// digit characters in "Infinity"/"-Infinity"/"NaN" (`format(value, "f")`
626    /// spells them exactly so), leaving only the `_ICAO_MINUS` sign word.
627    pub(crate) fn cardinal_words(&self) -> &'static str {
628        match self {
629            AeroSpecial::Inf { negative: true } => "minus",
630            _ => "",
631        }
632    }
633
634    /// The raise from `int(value)` — reached inside `verify_ordinal`'s
635    /// `value == int(value)` comparison (ordinal / ordinal_num) and inside
636    /// the currency delegate. Types and messages are CPython's `decimal`
637    /// module's own.
638    pub(crate) fn int_error(&self) -> N2WError {
639        match self {
640            AeroSpecial::Inf { .. } => {
641                N2WError::Overflow("cannot convert Infinity to integer".into())
642            }
643            AeroSpecial::NaN => N2WError::Value("cannot convert NaN to integer".into()),
644        }
645    }
646}
647
648/// `converter.str_to_number` for every AERO profile: base `Decimal(value)`
649/// semantics with Infinity/NaN mapped to the sentinels above.
650pub(crate) fn aero_str_to_number(s: &str) -> Result<ParsedNumber> {
651    match python_decimal_parse(s)? {
652        ParsedNumber::Inf { negative } => Ok(ParsedNumber::Dec(BigDecimal::new(
653            BigInt::from(if negative { -1 } else { 1 }),
654            -AERO_INF_EXPONENT,
655        ))),
656        ParsedNumber::NaN => Ok(ParsedNumber::Dec(BigDecimal::new(
657            BigInt::from(1),
658            -AERO_NAN_EXPONENT,
659        ))),
660        other => Ok(other),
661    }
662}
663
664/// Un-smuggle a sentinel from a raw `BigDecimal` (currency path).
665pub(crate) fn aero_special_of_decimal(d: &BigDecimal) -> Option<AeroSpecial> {
666    let (m, scale) = d.as_bigint_and_exponent();
667    if scale == -AERO_INF_EXPONENT {
668        return Some(AeroSpecial::Inf {
669            negative: m.sign() == Sign::Minus,
670        });
671    }
672    if scale == -AERO_NAN_EXPONENT {
673        return Some(AeroSpecial::NaN);
674    }
675    None
676}
677
678/// Un-smuggle a sentinel from the float-entry `FloatValue`. Only the
679/// `Decimal` arm can carry one — the sentinels are born in
680/// [`aero_str_to_number`] as `ParsedNumber::Dec`.
681pub(crate) fn aero_special_of(v: &FloatValue) -> Option<AeroSpecial> {
682    match v {
683        FloatValue::Decimal { value, .. } => aero_special_of_decimal(value),
684        FloatValue::Float { .. } => None,
685    }
686}