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}