Skip to main content

num2words2_core/
lang_pt_br.rs

1//! Port of `lang_PT_BR.py` (Brazilian Portuguese).
2//!
3//! Registry key `"pt_BR"` → `Num2Word_PT_BR` (confirmed in `__init__.py`).
4//!
5//! Shape: **hybrid, reported as self-contained**. `Num2Word_PT_BR` overrides
6//! `to_cardinal` with its own short-scale algorithm, but that algorithm calls
7//! `super().to_cardinal(...)` for every value below 10^9, and *that* super is
8//! `Num2Word_PT.to_cardinal` → `Num2Word_Base.to_cardinal`, i.e. the real
9//! `splitnum`/`clean`/`merge` engine driven by `Num2Word_PT_BR.merge`.
10//!
11//! So both halves are live and both are ported:
12//!   * [`LangPtBr::super_to_cardinal`] == `Num2Word_PT.to_cardinal`
13//!     ([`base::default_to_cardinal`] + PT's regex fixups). `cards`, `maxval`
14//!     and `merge` are supplied so the engine runs.
15//!   * [`Lang::to_cardinal`] == `Num2Word_PT_BR.to_cardinal`, the short-scale
16//!     bilhão/trilhão wrapper on top.
17//!
18//! # Inheritance chain
19//!
20//! `Num2Word_PT_BR` → `Num2Word_PT` → `Num2Word_EUR` → `Num2Word_Base`.
21//! (`lang_EU` / `Num2Word_EU` is Basque and is *not* in this chain despite the
22//! similar name.) Resolved members:
23//!   * `setup`: EUR sets `high_numwords`, PT overwrites it, PT_BR overwrites
24//!     `low_numwords` + `thousand_separators`. Only the final values matter —
25//!     `set_numwords()` runs after `setup()` returns.
26//!   * `set_high_numwords`: EUR's. `GIGA_SUFFIX = None` (PT) so the illiard
27//!     rung is skipped entirely; only `MEGA_SUFFIX = "ilião"` fires.
28//!   * `merge`: PT_BR's (below). PT's is fully shadowed.
29//!   * `to_ordinal` / `to_ordinal_num` / `to_year`: PT's, inherited unchanged.
30//!   * `verify_ordinal`: `Num2Word_Base`'s → TypeError on negatives.
31//!
32//! # Scale of the card table
33//!
34//! PT's `setup` replaces EUR's Latin-prefix tower with
35//! `gen_high_numwords([], [], lows)`; with `tens == []` the comprehension is
36//! empty, so `high_numwords` collapses to `lows` itself:
37//! `["non","oct","sept","sext","quint","quatr","tr","b","m"]`. EUR's
38//! `set_high_numwords` then pairs them with `range(57, 3, -6)` under
39//! `MEGA_SUFFIX`, giving cards 10^54 "nonilião" … 10^6 "milião", and
40//! `MAXVAL = 1000 * 10^54 = 10^57`. Verified against the interpreter.
41//!
42//! **The overflow check is unreachable.** `Num2Word_PT_BR.to_cardinal` only
43//! ever hands `super()` a value `< 10^9` (see the per-branch notes below), so
44//! `default_to_cardinal`'s `>= MAXVAL` test never fires and no card above
45//! 10^6 is ever consulted. The table is still built in full for fidelity.
46//!
47//! # Faithfully reproduced Python quirks
48//!
49//! This is a port, not a rewrite. All of the following are exactly what Python
50//! emits, verified against the interpreter:
51//!
52//! 1. **"milião" leaks out of `merge` and is patched by string replace.** The
53//!    card for 10^6 is literally `"milião"` (PT's European `MEGA_SUFFIX`).
54//!    PT's `merge` repairs it with `if ntext == "milião": ntext = "milhão"`,
55//!    but **PT_BR's `merge` drops that line**, so `super().to_cardinal(10**6)`
56//!    really returns `"um milião"`. PT_BR instead scrubs it after the fact
57//!    with `.replace("milião", "milhão")` at each call site — see
58//!    [`fix_milhao`]. Reachable: `to_cardinal(1_001_500_000)` routes
59//!    `1_500_000` through `super()`, which yields "um milião e quinhentos mil"
60//!    before the scrub. The companion `.replace("miliões", "milhões")` is dead
61//!    code under PT_BR's `merge` (it has no `"liões"` branch), but is kept.
62//! 2. **Two regex passes fight over the same comma.** PT's `to_cardinal`
63//!    *deletes* the "e" in "mil e cento" → "mil cento" ([`pt_drops_e`]), then
64//!    PT_BR's `to_cardinal` *inserts* a comma → "mil, cento" ([`ptbr_comma`]).
65//!    Net effect for 1101: "mil e cento e um" → "mil cento e um" → "mil,
66//!    cento e um". Both passes are needed; neither alone is correct.
67//! 3. **PT_BR's trailing regex loop only ever runs for `value < 10^6`.** Every
68//!    other branch of `to_cardinal` `return`s early. That is why 1234567890 is
69//!    "um bilhão, duzentos e trinta e quatro milhões quinhentos e sessenta e
70//!    sete mil oitocentos e noventa" — comma after "bilhão" (added by hand by
71//!    the billions branch), but *no* comma after "milhões" (the loop that
72//!    would have added one is skipped by the early return).
73//! 4. **`to_ordinal` mixes `self`- and `super`-style helpers inconsistently**,
74//!    and its sibling `to_cardinal` does too: the millions branch converts the
75//!    *count* with `super()` but the *remainder* with `self`, while the
76//!    billions branch does the exact opposite. Preserved verbatim.
77//! 5. **`ords[2][4]` is "quadrigentésimo"**, not the standard
78//!    "quadringentésimo". Kept verbatim (drives `to_ordinal(123456)`).
79//! 6. `to_ordinal` strips a leading "primeiro " with `result[9:]` so that
80//!    1000 is "milésimo" rather than "primeiro milésimo" — but the guard is
81//!    the *string* `value != "1"`, not the number.
82//!
83//! # The currency surface
84//!
85//! Three layers, all live, all reached on every call:
86//!
87//! ```text
88//! Num2Word_PT_BR.to_currency   normalise whole float/Decimal -> int;
89//!                              then patch in " de" using the *Brazilian*
90//!                              scale words (bilhão/trilhão)
91//!   -> Num2Word_PT.to_currency int and float split here; patches in " de"
92//!                              using the *European* words (bilião/trilião)
93//!     -> Num2Word_Base.to_currency   floats only
94//! ```
95//!
96//! `CURRENCY_FORMS` is a fresh dict in PT_BR's own class body, so it shadows
97//! PT's and EUR's outright and holds exactly three codes: BRL, EUR, USD.
98//! Confirmed against the live interpreter — **PT_BR does not see EN's
99//! mutations of the shared `Num2Word_EUR.CURRENCY_FORMS`** (the trap
100//! `PORTING_CURRENCY.md` warns about), because it never reads that dict.
101//! `CURRENCY_ADJECTIVES` *is* EUR's shared dict (PT_BR defines none), and EN
102//! never writes to it, so it is EUR's 16 literal entries.
103//! `CURRENCY_PRECISION` resolves to `Num2Word_Base`'s `{}` — EN *rebinds*
104//! rather than mutates it, so nothing leaks in — hence the divisor is 100 for
105//! every code and neither the 3-decimal (KWD/BHD) nor the 0-decimal (JPY)
106//! branch is reachable. That is why [`Lang::currency_precision`] is left at
107//! its default here.
108//!
109//! ## Faithfully reproduced currency quirks
110//!
111//! 7. **An unknown currency code plus an `int` does not raise — it returns a
112//!    bare cardinal.** `Num2Word_PT.to_currency`'s integer branch answers a
113//!    `KeyError` from `CURRENCY_FORMS` with `return self.to_cardinal(val)`,
114//!    dropping the currency word entirely; PT_BR's own post-pass then catches
115//!    its own `KeyError` and passes. So `to_currency(100, "JPY")` is `"cem"`,
116//!    not `NotImplementedError`. Only the *float* path raises, and it raises
117//!    from `Num2Word_Base.to_currency` before PT's `cr1[1]` lookup can turn
118//!    into a `KeyError`. The corpus pins both halves: `currency:JPY` of `0`
119//!    is `"zero"` while `currency:JPY` of `12.34` is `NotImplementedError`.
120//! 8. **A whole float or Decimal is demoted to `int` before anything else**,
121//!    so it takes the no-cents integer path. `to_currency(1.0, "EUR")` is
122//!    `"um euro"`, not `"um euro e zero cêntimos"` — PT_BR undoes the very
123//!    `isinstance(val, int)` distinction that `has_decimal` exists to carry.
124//!    `1.0` with an *unknown* code therefore also stops raising and yields
125//!    `"um"` (quirk 7 + this one compounding).
126//! 9. **Both " de" passes run, over different word lists.** PT's list is the
127//!    European "bilião/biliões/trilião/triliões"; PT_BR's is the Brazilian
128//!    "bilhão/bilhões/trilhão/trilhões". PT_BR's `to_cardinal` only ever
129//!    emits the Brazilian forms, so PT's pass is dead for those rungs and
130//!    PT_BR's pass is what fires. "milhão"/"milhões" appear in *both* lists,
131//!    but PT's pass runs first and its rewrite ("milhão" -> "milhão de")
132//!    breaks the "{ext} {currency}" adjacency PT_BR's guard needs, so the
133//!    second pass cannot double-insert. See [`insert_de`].
134//! 10. **The float path never gets " de" above 10^6 — a real grammatical bug,
135//!    reproduced.** PT_BR's Brazilian-word pass is gated on
136//!    `isinstance(val, int)`, so a non-integral value only ever sees PT's
137//!    European list, which cannot match "bilhão"/"trilhão". The two rungs
138//!    therefore disagree with each other:
139//!
140//!    ```text
141//!    1000000000    EUR -> "um bilhão de euros"                  (int path)
142//!    1000000000.5  EUR -> "um bilhão euros e cinquenta cêntimos" (float path)
143//!    ```
144//!
145//!    "milhão" escapes this because it is in both lists, so `1000000.5` does
146//!    get its "de". Verified against the interpreter; do not "fix" it.
147//! 11. **`adjective=True` is silently ignored on the integer path.** PT's
148//!    integer branch never calls `prefix_currency`, unlike `Num2Word_Base`'s.
149//!    Only the float path honours the flag. (Moot for all but USD anyway —
150//!    it is the one code present in both `CURRENCY_ADJECTIVES` and PT_BR's
151//!    three-code `CURRENCY_FORMS`.)
152//!
153//! # Strings, floats and fractions
154//!
155//! * `str_to_number` (issue #63): a string with '.' and no ',' is US-style
156//!   and is pronounced "ponto" instead of "vírgula" — Python stashes
157//!   `_pending_pointword` on the instance and `to_cardinal_float` consumes
158//!   it. Ported as `ParsedNumber::DecPoint` + [`Lang::cardinal_with_pointword`].
159//!   "Infinity"/"NaN" strings raise `decimal.InvalidOperation` (from PT_BR's
160//!   own `to_cardinal`: `divmod(Inf, 10**12)` / the `NaN < 0` comparison),
161//!   not the base OverflowError/ValueError — see [`Lang::str_to_number`].
162//! * `to_ordinal(float)` truncates via `int(value)` **before**
163//!   `verify_ordinal` (PT's code, inherited): 2.5 -> "segundo", -1.5 ->
164//!   TypeError (negord). `to_ordinal_num(float)` verifies the raw value:
165//!   fractional -> TypeError (floatord) first, then numerically-negative;
166//!   -0.0 passes both and yields "-0.0º".
167//! * `to_year(float)`: `val < 0` numeric; negative renders
168//!   `to_cardinal(abs(val)) + " antes de Cristo"`. Fractional *Decimals* are
169//!   declined (NotImplemented -> Python fallback) because year mode cannot
170//!   see a dotted string's pending "ponto" — see [`Lang::year_float_entry`].
171//! * `to_fraction` is PT's override, inherited: "meio"/"terço" idiomatic
172//!   forms, conditional "s" plural, dispatching to PT_BR's own
173//!   cardinal/ordinal ("1000000/3" -> "um milhão terços").
174//!
175//! # Error variants
176//!
177//! * `to_ordinal(n)` / `to_ordinal_num(n)` for any `n < 0` → `TypeError`
178//!   (deliberate, from `Num2Word_Base.verify_ordinal`) → [`N2WError::Type`].
179//! * `to_ordinal(n)` for `n >= 10^18` → **KeyError**: the digit loop asks for
180//!   `thousand_separators[18]` and PT_BR's table stops at 15. This is a crash,
181//!   not a deliberate raise, but the type is observable → [`N2WError::Key`].
182//!   Note 18-digit values are fine (max idx 17, and 17 % 3 != 0); the failure
183//!   starts at 19 digits.
184//! * `to_cardinal` has no reachable error path at all (see "Scale" above).
185//! * `to_currency` / `to_cheque` for a code outside {BRL, EUR, USD} →
186//!   `NotImplementedError` → [`N2WError::NotImplemented`], but for
187//!   `to_currency` *only* when the value is not an integer (quirk 7).
188
189use crate::base::{default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
190use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
191use crate::floatpath::{float2tuple, FloatValue};
192use crate::strnum::{python_decimal_parse, ParsedNumber};
193use bigdecimal::BigDecimal;
194use num_bigint::BigInt;
195use num_integer::Integer;
196use num_traits::{FromPrimitive, One, Signed, Zero};
197use std::collections::HashMap;
198
199/// `Num2Word_PT.hundreds`, keys 1..=9. Index 0 is absent in Python.
200const HUNDREDS: [&str; 10] = [
201    "", // absent in Python
202    "cento",
203    "duzentos",
204    "trezentos",
205    "quatrocentos",
206    "quinhentos",
207    "seiscentos",
208    "setecentos",
209    "oitocentos",
210    "novecentos",
211];
212
213/// `Num2Word_PT.ords` — three rungs of ten, units / tens / hundreds.
214///
215/// `ords[2][4]` is "quadrigentésimo" in the Python source (not
216/// "quadringentésimo"); preserved verbatim.
217const ORDS: [[&str; 10]; 3] = [
218    [
219        "", "primeiro", "segundo", "terceiro", "quarto", "quinto", "sexto", "sétimo", "oitavo",
220        "nono",
221    ],
222    [
223        "",
224        "décimo",
225        "vigésimo",
226        "trigésimo",
227        "quadragésimo",
228        "quinquagésimo",
229        "sexagésimo",
230        "septuagésimo",
231        "octogésimo",
232        "nonagésimo",
233    ],
234    [
235        "",
236        "centésimo",
237        "ducentésimo",
238        "tricentésimo",
239        "quadrigentésimo",
240        "quingentésimo",
241        "seiscentésimo",
242        "septigentésimo",
243        "octigentésimo",
244        "nongentésimo",
245    ],
246];
247
248/// Python's `\w` for `str`: `[a-zA-Z0-9_]` plus any Unicode alphanumeric.
249///
250/// The vocabulary here is Portuguese, so the accented letters (é, ã, ê, õ, ç,
251/// í, ó, ú, á, â) must count as word characters — `is_alphanumeric` covers
252/// them, whereas an ASCII-only test would not.
253fn is_word_char(c: char) -> bool {
254    c.is_alphanumeric() || c == '_'
255}
256
257/// `result.replace("milião", "milhão").replace("miliões", "milhões")`.
258///
259/// See quirk 1 in the module docs: PT_BR's `merge` lacks PT's "milião" repair,
260/// so the raw card word escapes and is scrubbed here instead. Python's
261/// `str.replace` is replace-all, as is Rust's.
262fn fix_milhao(s: &str) -> String {
263    s.replace("milião", "milhão").replace("miliões", "milhões")
264}
265
266/// Python's `re.match(".*{ext} e \\w*entos? (?=.*e)", result)`.
267///
268/// `re.match` anchors at 0 but the leading `.*` (greedy, backtracking) makes
269/// it an unanchored existence test. Decomposition of `\w*entos? `:
270/// after the literal "{ext} e " at `j`, `\w*` greedily eats the word-char run
271/// `[j, k)`, then backtracks to place "ento" + optional "s" ending at some
272/// `m <= k` with `result[m] == ' '`. A space is not a word char, so `m == k`
273/// is forced — i.e. the whole run must end in "ento"/"entos" and be followed
274/// by a space. The `(?=.*e)` lookahead at `k + 1` then just asks whether an
275/// 'e' occurs anywhere in the tail.
276fn pt_match(s: &str, ext: &str) -> bool {
277    let chars: Vec<char> = s.chars().collect();
278    let pat: Vec<char> = format!("{} e ", ext).chars().collect();
279    if chars.len() < pat.len() {
280        return false;
281    }
282    for start in 0..=(chars.len() - pat.len()) {
283        if chars[start..start + pat.len()] != pat[..] {
284            continue;
285        }
286        let j = start + pat.len();
287        let mut k = j;
288        while k < chars.len() && is_word_char(chars[k]) {
289            k += 1;
290        }
291        // `\w*entos?` must be followed by the pattern's literal space.
292        if k >= chars.len() || chars[k] != ' ' {
293            continue;
294        }
295        let w: String = chars[j..k].iter().collect();
296        // "entos".ends_with("ento") is false, so both arms are needed.
297        if !(w.ends_with("ento") || w.ends_with("entos")) {
298            continue;
299        }
300        // Lookahead (?=.*e) evaluated after the consumed space.
301        if chars[k + 1..].iter().any(|&c| c == 'e') {
302            return true;
303        }
304    }
305    false
306}
307
308/// `Num2Word_PT.to_cardinal`'s fixup: `result.replace("{ext} e", "{ext}")`.
309///
310/// Turns "mil e cento e catorze" into "mil cento e catorze". Replace-all, and
311/// deliberately a plain string replace rather than a regex in Python.
312fn pt_drops_e(result: &str, ext: &str) -> String {
313    result.replace(&format!("{} e", ext), ext)
314}
315
316/// Python's `re.search("{ext} \\w*ento", r)` guard plus
317/// `re.sub("({ext}) (\\w*entos?)", r"\1, \2", r, count=1)`.
318///
319/// Returns the rewritten string, or `None` when the guard fails.
320///
321/// Here `\w*ento` has nothing after it, so unlike [`pt_match`] the word run
322/// need only *contain* "ento" (the greedy `\w*` backtracks to expose it), not
323/// end with it. Guard and substitution share the same leftmost start: `s?` is
324/// optional, so anywhere `\w*ento` matches, `\w*entos?` matches too.
325///
326/// The substitution is capture-preserving — `\1` is `{ext}` and `\2` is the
327/// (possibly partial) word — so the whole rewrite reduces to inserting a comma
328/// in place of that one space, leaving any unmatched tail of the word alone.
329fn ptbr_comma(s: &str, ext: &str) -> Option<String> {
330    let chars: Vec<char> = s.chars().collect();
331    let pat: Vec<char> = format!("{} ", ext).chars().collect();
332    if chars.len() < pat.len() {
333        return None;
334    }
335    for start in 0..=(chars.len() - pat.len()) {
336        if chars[start..start + pat.len()] != pat[..] {
337            continue;
338        }
339        let j = start + pat.len();
340        let mut k = j;
341        while k < chars.len() && is_word_char(chars[k]) {
342            k += 1;
343        }
344        let w: String = chars[j..k].iter().collect();
345        if !w.contains("ento") {
346            continue;
347        }
348        // Matched span is "{ext} {X}", replacement "{ext}, {X}": the space at
349        // j - 1 becomes ", " and everything else is carried through.
350        let mut out: String = chars[..j - 1].iter().collect();
351        out.push_str(", ");
352        out.extend(chars[j..].iter());
353        return Some(out);
354    }
355    None
356}
357
358/// `Num2Word_PT_BR.CURRENCY_FORMS` — the class body's own dict, verbatim.
359///
360/// PT_BR rebinds the name in its class body, so PT's 14-code table and EUR's
361/// 23-code table are both shadowed and *neither* is consulted at runtime.
362/// That also puts PT_BR out of reach of `Num2Word_EN.__init__`'s in-place
363/// mutation of `Num2Word_EUR.CURRENCY_FORMS`: verified with
364/// `c.CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS` → `False`.
365///
366/// Every entry carries exactly two forms, which is all `Num2Word_EUR`'s
367/// `pluralize` ever indexes.
368fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
369    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
370    m.insert("BRL", CurrencyForms::new(&["real", "reais"], &["centavo", "centavos"]));
371    m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &["cêntimo", "cêntimos"]));
372    m.insert("USD", CurrencyForms::new(&["dólar", "dólares"], &["centavo", "centavos"]));
373    m
374}
375
376/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged (PT_BR and PT both
377/// define none, and EN never writes to this dict — only to `CURRENCY_FORMS`).
378///
379/// Almost entirely dead weight here: only USD overlaps PT_BR's three codes, so
380/// USD is the single adjective that can ever be applied. Kept whole because it
381/// is what the attribute lookup actually resolves to.
382fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
383    [
384        ("AUD", "Australian"),
385        ("BYN", "Belarusian"),
386        ("CAD", "Canadian"),
387        ("EEK", "Estonian"),
388        ("USD", "US"),
389        ("RUB", "Russian"),
390        ("NOK", "Norwegian"),
391        ("MXN", "Mexican"),
392        ("RON", "Romanian"),
393        ("INR", "Indian"),
394        ("HUF", "Hungarian"),
395        ("ISK", "íslenskar"),
396        ("UZS", "Uzbekistan"),
397        ("SAR", "Saudi"),
398        ("JPY", "Japanese"),
399        ("KRW", "Korean"),
400    ]
401    .into_iter()
402    .collect()
403}
404
405/// `Num2Word_PT.to_currency`'s scale words — European long scale.
406const PT_DE_EXTS: [&str; 6] = ["milhão", "milhões", "bilião", "biliões", "trilião", "triliões"];
407
408/// `Num2Word_PT_BR.to_currency`'s scale words — Brazilian short scale.
409const PT_BR_DE_EXTS: [&str; 6] = ["milhão", "milhões", "bilhão", "bilhões", "trilhão", "trilhões"];
410
411/// One iteration of the `" de"` insertion loop both PT and PT_BR run:
412///
413/// ```python
414/// if re.match(".*{ext} (?={currency_str})".format(...), result):
415///     result = result.replace("{ext}", "{ext} de".format(ext), 1)
416/// ```
417///
418/// Returns the rewritten string, or `None` when the guard fails.
419///
420/// The guard is `re.match`, anchored at 0 — but the leading `.*` is greedy
421/// *with backtracking*, so it degrades to a plain existence test for the
422/// literal "{ext} {currency_str}". Two things make that exact rather than
423/// approximate: every `ext` and every currency word in PT_BR's table is plain
424/// text with no regex metacharacters, and no output ever contains a newline
425/// (the one character `.` would refuse to cross).
426///
427/// The replacement is a **`str.replace` with `count=1`, not a `re.sub`**: it
428/// rewrites the first occurrence of `ext` *anywhere* in the string, which need
429/// not be the occurrence the guard matched. Preserved verbatim — with these
430/// tables the two always coincide, because `to_cardinal` emits at most one
431/// scale word per rung and "milhão" is not a substring of "milhões".
432fn insert_de(result: &str, ext: &str, currency_str: &str) -> Option<String> {
433    if result.contains(&format!("{} {}", ext, currency_str)) {
434        Some(result.replacen(ext, &format!("{} de", ext), 1))
435    } else {
436        None
437    }
438}
439
440/// Python's `re.sub("\\s+", " ", value)`.
441fn collapse_ws(s: &str) -> String {
442    let mut out = String::new();
443    let mut in_ws = false;
444    for c in s.chars() {
445        if c.is_whitespace() {
446            if !in_ws {
447                out.push(' ');
448                in_ws = true;
449            }
450        } else {
451            out.push(c);
452            in_ws = false;
453        }
454    }
455    out
456}
457
458/// `abs(Decimal(repr(f)).as_tuple().exponent)` for an f64 — the count of
459/// fractional digits in its shortest round-trip repr.
460///
461/// Mirrors `floatpath::float_repr_precision` (private there). Used to re-derive
462/// the precision of a float sub-value produced by float-domain `divmod`, which
463/// is what `base.float2tuple` does when `Num2Word_PT_BR.to_cardinal` re-enters
464/// the float path on a low group. Rust's `{}` for f64 is shortest round-trip,
465/// the same contract as Python's `repr`.
466fn float_repr_precision(f: f64) -> u32 {
467    let s = format!("{}", f);
468    match s.split_once('.') {
469        Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
470        _ => 0,
471    }
472}
473
474/// Python's `int(value)` on a float/Decimal: truncation toward zero
475/// (`int(-1.5) == -1`, `int(-0.0) == 0`). NaN/inf raise what CPython raises —
476/// unreachable through the shim (its `_finite` guard keeps them on the Python
477/// side), kept for fidelity.
478fn float_trunc_int(value: &FloatValue) -> Result<BigInt> {
479    match value {
480        FloatValue::Float { value: f, .. } => {
481            if f.is_nan() {
482                return Err(N2WError::Value(
483                    "cannot convert float NaN to integer".into(),
484                ));
485            }
486            if f.is_infinite() {
487                return Err(N2WError::Overflow(
488                    "cannot convert float infinity to integer".into(),
489                ));
490            }
491            // Exact: every whole f64 is exactly representable as an integer.
492            Ok(BigInt::from_f64(f.trunc()).expect("finite whole f64 converts exactly"))
493        }
494        FloatValue::Decimal { value: d, .. } => {
495            // int(Decimal): with_scale(0) divides the mantissa by 10^scale in
496            // BigInt, which truncates toward zero — exactly Python's int().
497            Ok(d.with_scale(0).as_bigint_and_exponent().0)
498        }
499    }
500}
501
502/// Python's `val < 0` on a float/Decimal — a *numeric* comparison, unlike
503/// `FloatValue::is_negative()` which is sign-bit aware. `-0.0 < 0` is False,
504/// so `to_year(-0.0)` renders through the positive branch.
505fn fv_numeric_negative(v: &FloatValue) -> bool {
506    match v {
507        FloatValue::Float { value, .. } => *value < 0.0,
508        FloatValue::Decimal { value, .. } => value.is_negative(),
509    }
510}
511
512/// Python's `abs(val)` in the value's own domain (float stays float,
513/// Decimal stays Decimal), keeping the recorded precision.
514fn fv_abs(v: &FloatValue) -> FloatValue {
515    match v {
516        FloatValue::Float { value, precision } => FloatValue::Float {
517            value: value.abs(),
518            precision: *precision,
519        },
520        FloatValue::Decimal { value, precision } => FloatValue::Decimal {
521            value: value.abs(),
522            precision: *precision,
523        },
524    }
525}
526
527pub struct LangPtBr {
528    cards: Cards,
529    maxval: BigInt,
530    hundreds: [&'static str; 10],
531    thousand_separators: HashMap<usize, &'static str>,
532    exclude_title: Vec<String>,
533    currency_forms: HashMap<&'static str, CurrencyForms>,
534    currency_adjectives: HashMap<&'static str, &'static str>,
535}
536
537impl Default for LangPtBr {
538    fn default() -> Self {
539        Self::new()
540    }
541}
542
543impl LangPtBr {
544    pub fn new() -> Self {
545        // PT.setup: high_numwords = self.gen_high_numwords([], [], lows).
546        // With tens == [] the list comprehension `[u + t for t in tens ...]`
547        // is empty, so gen_high_numwords returns `[] + lows` == lows.
548        let high = ["non", "oct", "sept", "sext", "quint", "quatr", "tr", "b", "m"];
549
550        let mut cards = Cards::new();
551
552        // Num2Word_EUR.set_high_numwords, with PT's GIGA_SUFFIX = None and
553        // MEGA_SUFFIX = "ilião":
554        //   cap = 3 + 6 * len(high) = 57
555        //   for word, n in zip(high, range(cap, 3, -6)):
556        //       if GIGA_SUFFIX: cards[10**n] = word + GIGA_SUFFIX   # skipped
557        //       if MEGA_SUFFIX: cards[10**(n-3)] = word + MEGA_SUFFIX
558        // zip() stops at the shorter sequence; both are length 9 here.
559        let cap: i64 = 3 + 6 * high.len() as i64;
560        let mut n = cap;
561        for word in high.iter() {
562            if n <= 3 {
563                break; // range(cap, 3, -6) is exclusive of 3
564            }
565            cards.insert(
566                BigInt::from(10u8).pow((n - 3) as u32),
567                format!("{}ilião", word),
568            );
569            n -= 6;
570        }
571
572        // PT.setup mid_numwords.
573        set_mid_numwords(
574            &mut cards,
575            &[
576                (1000, "mil"),
577                (100, "cem"),
578                (90, "noventa"),
579                (80, "oitenta"),
580                (70, "setenta"),
581                (60, "sessenta"),
582                (50, "cinquenta"),
583                (40, "quarenta"),
584                (30, "trinta"),
585            ],
586        );
587
588        // PT_BR.setup low_numwords — overrides PT's list. Brazilian spelling
589        // differs at 16 / 17 / 19 (dezesseis / dezessete / dezenove vs the
590        // European dezasseis / dezassete / dezanove). 14 stays "catorze".
591        set_low_numwords(
592            &mut cards,
593            &[
594                "vinte",
595                "dezenove",
596                "dezoito",
597                "dezessete",
598                "dezesseis",
599                "quinze",
600                "catorze",
601                "treze",
602                "doze",
603                "onze",
604                "dez",
605                "nove",
606                "oito",
607                "sete",
608                "seis",
609                "cinco",
610                "quatro",
611                "três",
612                "dois",
613                "um",
614                "zero",
615            ],
616        );
617
618        // Num2Word_Base.__init__: MAXVAL = 1000 * list(self.cards.keys())[0].
619        // The first key inserted is the largest (10^54), so `highest()` agrees
620        // with Python's insertion-order indexing. => MAXVAL == 10^57.
621        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
622
623        // PT_BR.setup thousand_separators — overrides PT's, which used the
624        // European long scale (9: "milésimo milionésimo", 12: "bilionésimo").
625        // Brazil is short scale: bilionésimo = 10^9, trilionésimo = 10^12.
626        // Nothing above 15, hence the KeyError at 10^18 (see module docs).
627        let thousand_separators: HashMap<usize, &'static str> = [
628            (3usize, "milésimo"),
629            (6, "milionésimo"),
630            (9, "bilionésimo"),
631            (12, "trilionésimo"),
632            (15, "quatrilionésimo"),
633        ]
634        .into_iter()
635        .collect();
636
637        LangPtBr {
638            cards,
639            maxval,
640            hundreds: HUNDREDS,
641            thousand_separators,
642            exclude_title: vec!["e".into(), "vírgula".into(), "menos".into()],
643            // Built once here, never per call. `to_currency` only ever reads
644            // these tables, and rebuilding them on every call is what made an
645            // earlier revision of this port slower than the Python it
646            // replaces. CURRENCY_PRECISION has no field: PT_BR resolves it to
647            // Num2Word_Base's empty dict, so `.get(code, 100)` is always 100 —
648            // exactly the trait default.
649            currency_forms: build_currency_forms(),
650            currency_adjectives: build_currency_adjectives(),
651        }
652    }
653
654    /// `Num2Word_PT.hundreds[cnum]`.
655    ///
656    /// Python would raise KeyError outside 1..=9, but `merge` cannot return an
657    /// error and the lookup is unreachable: it fires only when `nnum == 100`,
658    /// which `splitnum` produces solely for `100 <= value < 1000`, giving
659    /// `cnum = value // 100` in 1..=9 — and `cnum == 1` has already returned.
660    /// So `cnum` is always 2..=9 here.
661    fn hundreds_word(&self, cnum: &BigInt) -> &'static str {
662        let idx = u32::try_from(cnum).unwrap_or(0) as usize;
663        if idx < self.hundreds.len() {
664            self.hundreds[idx]
665        } else {
666            ""
667        }
668    }
669
670    /// `super(Num2Word_PT_BR, self).to_cardinal(value)` — i.e.
671    /// `Num2Word_PT.to_cardinal`: the `Num2Word_Base` engine (splitnum /
672    /// clean / PT_BR's merge) followed by PT's "mil e cento" → "mil cento"
673    /// regex pass.
674    ///
675    /// Every caller passes `value < 10^9`, so neither the MAXVAL check nor any
676    /// card above 10^6 is reachable through this path.
677    fn super_to_cardinal(&self, value: &BigInt) -> Result<String> {
678        let mut result = default_to_cardinal(self, value)?;
679
680        // Transforms "mil e cento e catorze" into "mil cento e catorze".
681        // Transforms "cem milhões e duzentos mil e duzentos e dez" into "cem
682        // milhões duzentos mil duzentos e dez" but "cem milhões e duzentos mil
683        // e duzentos" into "cem milhões duzentos mil e duzentos" and not into
684        // "cem milhões duzentos mil duzentos" — the (?=.*e) lookahead is what
685        // spares the final group.
686        for ext in [
687            "mil",
688            "milhão",
689            "milhões",
690            "mil milhões",
691            "bilião",
692            "biliões",
693            "mil biliões",
694        ] {
695            if pt_match(&result, ext) {
696                result = pt_drops_e(&result, ext);
697            }
698        }
699        Ok(result)
700    }
701
702    /// `Num2Word_PT.to_cardinal(value)` for a **non-integer** `value >= 0` —
703    /// i.e. `Num2Word_Base.to_cardinal_float(value)` followed by PT's
704    /// "mil e cento" → "mil cento" e-drop pass.
705    ///
706    /// `base.to_cardinal_float` renders the integer part with
707    /// `self.to_cardinal(pre)` (so `pre >= 10^6` picks up PT_BR's short-scale
708    /// commas — this is exactly the divergence a plain
709    /// `default_to_cardinal_float` gets wrong: it would route the low group
710    /// through the base engine and drop the comma), then joins "vírgula" and
711    /// one word per fractional digit.
712    ///
713    /// `float2tuple` is recomputed here on the *leaf* sub-value, exactly as
714    /// Python's `super().to_cardinal(rest)` re-enters `base.to_cardinal_float`.
715    /// For a `Decimal` that is a no-op (precision is preserved exactly through
716    /// `divmod`); for a `float` it is load-bearing — the low group's own
717    /// `repr` precision is re-derived from the f64-error-corrupted remainder,
718    /// which is how `98746251323029.99` differs from a large binary float.
719    ///
720    /// `pointword` is threaded in rather than read from `self`: Python's
721    /// `Num2Word_PT_BR.to_cardinal_float` swaps `self.pointword` to the
722    /// pending "ponto" for the duration of the call when `str_to_number` saw
723    /// a US-style dotted string (see [`Lang::str_to_number`]); the swap is
724    /// visible here and nowhere else.
725    fn super_float(&self, fv: &FloatValue, pointword: &str) -> Result<String> {
726        let precision = fv.precision();
727        // The sub-value is always >= 0 (the sign is peeled off in
728        // `to_cardinal_float`), so `pre` is non-negative and
729        // `base.to_cardinal_float`'s `value < 0 and pre == 0` negword branch is
730        // dead and omitted.
731        let (pre, post) = float2tuple(fv);
732        let pre = pre.abs();
733
734        // post = str(post); left-padded to `precision` — same as base.
735        let post_str = post.to_string();
736        let post_str = format!(
737            "{}{}",
738            "0".repeat((precision as usize).saturating_sub(post_str.len())),
739            post_str
740        );
741
742        let mut out = vec![self.to_cardinal(&pre)?];
743        if precision > 0 {
744            out.push(self.title(pointword));
745        }
746        for ch in post_str.chars().take(precision as usize) {
747            let d = ch
748                .to_digit(10)
749                .ok_or_else(|| N2WError::Value(format!("non-digit {:?} in fractional part", ch)))?;
750            out.push(self.to_cardinal(&BigInt::from(d))?);
751        }
752        let mut result = out.join(" ");
753
754        // `Num2Word_PT.to_cardinal`'s e-drop pass, over the full float string.
755        // A no-op in practice (the integer part was already processed by
756        // `self.to_cardinal(pre)`, and "vírgula"/digit words never end in
757        // "ento"), but applied verbatim for fidelity.
758        for ext in [
759            "mil",
760            "milhão",
761            "milhões",
762            "mil milhões",
763            "bilião",
764            "biliões",
765            "mil biliões",
766        ] {
767            if pt_match(&result, ext) {
768                result = pt_drops_e(&result, ext);
769            }
770        }
771        Ok(result)
772    }
773
774    /// `divmod(value, 10**pow)`, reproducing Python's per-type semantics on a
775    /// non-negative sub-value. Returns `(quotient, remainder)` where the
776    /// quotient is a whole `BigInt` and the remainder is a `FloatValue` of the
777    /// same variant that carries the fraction on down.
778    ///
779    /// * `Decimal`: exact. `q = int(value) // 10**pow`, and the remainder keeps
780    ///   the *original* precision, because subtracting an exponent-0 multiple in
781    ///   `Decimal` arithmetic preserves the exponent.
782    /// * `float`: IEEE `fmod`. For a non-negative dividend Python's
783    ///   `float.__divmod__` yields `(round((v - fmod(v, d)) / d), fmod(v, d))`,
784    ///   and Rust's `%` *is* `fmod`; the remainder then re-derives its own
785    ///   `repr` precision — the whole point of doing this in the float domain.
786    fn fv_divmod(&self, fv: &FloatValue, pow: u32) -> (BigInt, FloatValue) {
787        match fv {
788            FloatValue::Float { value, .. } => {
789                let d = 10f64.powi(pow as i32);
790                let rem = value % d;
791                let q = ((value - rem) / d).round();
792                (
793                    BigInt::from(q as i128),
794                    FloatValue::Float {
795                        value: rem,
796                        precision: float_repr_precision(rem),
797                    },
798                )
799            }
800            FloatValue::Decimal { value, precision } => {
801                let divisor = BigInt::from(10).pow(pow);
802                // int(value) truncates toward zero; value >= 0 so == floor.
803                let pre = value.with_scale(0).as_bigint_and_exponent().0;
804                let q = &pre / &divisor;
805                let rem = value - BigDecimal::from(&q * &divisor);
806                (
807                    q,
808                    FloatValue::Decimal {
809                        value: rem,
810                        precision: *precision,
811                    },
812                )
813            }
814        }
815    }
816
817    /// `Num2Word_PT_BR.to_cardinal(value)` for a **non-integer** `value >= 0`.
818    ///
819    /// Structurally identical to the integer [`Lang::to_cardinal`] short-scale
820    /// wrapper, with three float-specific consequences of the fraction:
821    ///   * the fraction rides in the lowest remainder, so `remainder` is always
822    ///     truthy and every `if remainder` block is unconditionally taken;
823    ///   * the tail renders through the float path ([`LangPtBr::super_float`],
824    ///     or a recursive call for the millions branch) instead of the integer
825    ///     `super_to_cardinal` / `to_cardinal`;
826    ///   * the `< 10^6` leaf is the base float render plus the same
827    ///     `fix_milhao` + comma pass the integer branch runs.
828    ///
829    /// The `divmod` is carried in the value's own domain ([`LangPtBr::fv_divmod`])
830    /// rather than on a shared `pre`, so a large binary `float`'s low groups
831    /// re-derive their own precision exactly as Python's float-domain `divmod`
832    /// makes them. Every magnitude test reduces to the integer part because
833    /// `0 <= frac < 1` (`remainder >= 100` ⇔ `int(remainder) >= 100`, and
834    /// `value >= 10^n` ⇔ `int(value) >= 10^n`, since each `10^n` is integral).
835    fn frac_cardinal(&self, fv: &FloatValue, pointword: &str) -> Result<String> {
836        let hundred = BigInt::from(100);
837        let e5 = BigInt::from(100_000u32);
838        let e6 = BigInt::from(1_000_000u32);
839        let e9 = BigInt::from(1_000_000_000u32);
840        let e12 = BigInt::from(1_000_000_000_000u64);
841
842        let pre = float2tuple(fv).0.abs();
843
844        if pre >= e9 {
845            // Handle trillions (10^12).
846            if pre >= e12 {
847                let (trillions, remainder) = self.fv_divmod(fv, 12);
848                let mut result = if trillions.is_one() {
849                    "um trilhão".to_string()
850                } else {
851                    format!("{} trilhões", self.to_cardinal(&trillions)?)
852                };
853
854                // remainder always carries the fraction → always truthy.
855                let rem_pre = float2tuple(&remainder).0.abs();
856                if rem_pre >= e9 {
857                    let (billions, rest) = self.fv_divmod(&remainder, 9);
858                    if billions.is_one() {
859                        result.push_str(", um bilhão");
860                    } else {
861                        result.push_str(&format!(", {} bilhões", self.to_cardinal(&billions)?));
862                    }
863                    // rest always carries the fraction → always truthy.
864                    let rest_str = fix_milhao(&self.super_float(&rest, pointword)?);
865                    result.push_str(&format!(" e {}", rest_str));
866                } else {
867                    let remainder_str = fix_milhao(&self.super_float(&remainder, pointword)?);
868                    result.push_str(&format!(" e {}", remainder_str));
869                }
870                return Ok(result);
871            }
872
873            // Handle billions (10^9).
874            let (billions, remainder) = self.fv_divmod(fv, 9);
875            let mut result = if billions.is_one() {
876                "um bilhão".to_string()
877            } else {
878                format!("{} bilhões", self.to_cardinal(&billions)?)
879            };
880
881            // remainder always truthy (fraction present).
882            let rem_pre = float2tuple(&remainder).0.abs();
883            let remainder_str = fix_milhao(&self.super_float(&remainder, pointword)?);
884            if rem_pre >= e5 {
885                result.push_str(&format!(", {}", remainder_str));
886            } else {
887                result.push_str(&format!(" e {}", remainder_str));
888            }
889            return Ok(result);
890        }
891
892        // For values below 1 billion but above a million, handle specially.
893        if pre >= e6 {
894            let (millions, remainder) = self.fv_divmod(fv, 6);
895            let mut result = if millions.is_one() {
896                "um milhão".to_string()
897            } else {
898                format!("{} milhões", self.super_to_cardinal(&millions)?)
899            };
900
901            // remainder always truthy (fraction present); rendered via `self`,
902            // so it recurses back into this routine (mirroring the integer
903            // branch's `self.to_cardinal(remainder)`).
904            let rem_pre = float2tuple(&remainder).0.abs();
905            let remainder_str = self.frac_cardinal(&remainder, pointword)?;
906            if rem_pre >= hundred {
907                result.push_str(&format!(", {}", remainder_str));
908            } else {
909                result.push_str(&format!(" e {}", remainder_str));
910            }
911            return Ok(result);
912        }
913
914        // For values below 1 million: the base float render, then the same
915        // fix_milhao + comma loop the integer `< 10^6` branch runs.
916        let mut result = fix_milhao(&self.super_float(fv, pointword)?);
917        for ext in [
918            "mil",
919            "milhão",
920            "milhões",
921            "bilhão",
922            "bilhões",
923            "trilhão",
924            "trilhões",
925            "quatrilhão",
926            "quatrilhões",
927        ] {
928            if let Some(rewritten) = ptbr_comma(&result, ext) {
929                result = rewritten;
930            }
931        }
932        Ok(result)
933    }
934
935    /// `Num2Word_PT_BR.to_cardinal` for a **non-integer** value, with the
936    /// pointword made explicit — the shared body behind both
937    /// [`Lang::to_cardinal_float`] (plain "vírgula") and
938    /// [`Lang::cardinal_with_pointword`] (the str_to_number "ponto"
939    /// handshake). The sign is peeled here, matching the Python
940    /// `"%s%s" % (negword, …)` wrapper (`base.to_cardinal_float`'s own
941    /// `pre == 0` negword branch is therefore dead for PT_BR and is not
942    /// reproduced).
943    fn cardinal_float_with_pointword(
944        &self,
945        value: &FloatValue,
946        pointword: &str,
947    ) -> Result<String> {
948        // `Num2Word_PT_BR.to_cardinal` negates first — `"%s%s" % (negword, …)`
949        // — and routes the magnitude, so the sign is peeled here and the
950        // routing sees a non-negative value throughout.
951        let abs_fv = fv_abs(value);
952        let body = self.frac_cardinal(&abs_fv, pointword)?;
953
954        if value.is_negative() {
955            // negword carries its own trailing space ("menos ").
956            Ok(format!("{}{}", self.negword(), body))
957        } else {
958            Ok(body)
959        }
960    }
961
962    /// `super(Num2Word_PT_BR, self).to_currency(...)` — i.e.
963    /// `Num2Word_PT.to_currency`, which splits int from float and then runs
964    /// the European-scale `" de"` pass.
965    ///
966    /// # The negword mutation is deliberately not modelled
967    ///
968    /// Both branches of the Python do
969    /// `backup = self.negword; self.negword = self.negword[:-1]` — trimming
970    /// "menos " to "menos" — and restore it afterwards. Within a call this is
971    /// **unobservable**: every read of the mutated value goes through
972    /// `self.negword.strip()`, which erases the very space that was removed,
973    /// and the only `to_cardinal` calls in scope take `abs(...)` so they never
974    /// reach `negword` at all. So this port simply reads `negword()` directly.
975    ///
976    /// Across calls it *is* observable, because the float branch restores
977    /// without a `try/finally` and `Num2Word_Base.to_currency` raises for an
978    /// unknown code — see the `concerns` note. That leak is cross-call
979    /// converter state, which this port is stateless by design and does not
980    /// reproduce; no corpus row depends on it.
981    fn pt_to_currency(
982        &self,
983        val: &CurrencyValue,
984        currency: &str,
985        cents: bool,
986        separator: &str,
987        adjective: bool,
988    ) -> Result<String> {
989        // is_integer = isinstance(val, int)
990        if let CurrencyValue::Int(v) = val {
991            let cr1 = match self.currency_forms.get(currency) {
992                Some(f) => &f.unit,
993                // except KeyError: return self.to_cardinal(val)
994                //
995                // Quirk 7: an unknown code does *not* raise here, it degrades
996                // to a bare cardinal with no currency word. Note this returns
997                // before the negword mutation, so `to_cardinal` sees the full
998                // "menos " and negatives render normally.
999                None => return self.to_cardinal(v),
1000            };
1001
1002            let minus_str = if v.is_negative() {
1003                format!("{} ", self.negword().trim())
1004            } else {
1005                String::new()
1006            };
1007            // abs(int(val) if isinstance(val, float) else val) — val is an int
1008            // on this branch, so the float cast is dead.
1009            let abs_val = v.abs();
1010            let money_str = self.to_cardinal(&abs_val)?;
1011
1012            // `cr1[0]` if abs_val == 1 else `cr1[1] if len(cr1) > 1 else cr1[0]`.
1013            // Note this open-codes the plural choice instead of calling
1014            // pluralize(), unlike Num2Word_Base.to_currency — same answer for
1015            // EUR's two-form rule, but it is what the source does.
1016            let currency_str = if abs_val.is_one() || cr1.len() <= 1 {
1017                cr1.first()
1018            } else {
1019                cr1.get(1)
1020            }
1021            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?
1022            .clone();
1023
1024            let mut result = format!("{}{} {}", minus_str, money_str, currency_str);
1025
1026            // transforms "milhões euros" em "milhões de euros"
1027            for ext in PT_DE_EXTS {
1028                if let Some(rewritten) = insert_de(&result, ext, &currency_str) {
1029                    result = rewritten;
1030                }
1031            }
1032            return Ok(result.trim().to_string());
1033        }
1034
1035        // For floats with a non-zero decimal part, use the parent class
1036        // implementation. This is the only path that can raise: Base's own
1037        // CURRENCY_FORMS lookup turns a missing code into NotImplementedError.
1038        let mut result = default_to_currency(self, val, currency, cents, separator, adjective)?;
1039
1040        // `cr1, _ = self.CURRENCY_FORMS[currency]`. Python would raise a raw
1041        // KeyError here, but it is unreachable: Base already raised
1042        // NotImplementedError for exactly the same missing key.
1043        let cr1 = match self.currency_forms.get(currency) {
1044            Some(f) => &f.unit,
1045            None => return Err(N2WError::Key(format!("'{}'", currency))),
1046        };
1047        // Python indexes cr1[1] unconditionally — the plural — rather than
1048        // consulting pluralize(). IndexError on a one-form entry; every entry
1049        // in PT_BR's table has two, so this cannot fire.
1050        let plural = cr1
1051            .get(1)
1052            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?
1053            .clone();
1054        for ext in PT_DE_EXTS {
1055            if let Some(rewritten) = insert_de(&result, ext, &plural) {
1056                result = rewritten;
1057            }
1058        }
1059        Ok(result)
1060    }
1061
1062    /// `Num2Word_Base.verify_ordinal`. The float check cannot fire on BigInt
1063    /// input; only the negative check survives, and it raises TypeError.
1064    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
1065        if value.is_negative() {
1066            return Err(N2WError::Type(format!(
1067                "Cannot treat negative num {} as ordinal.",
1068                value
1069            )));
1070        }
1071        Ok(())
1072    }
1073}
1074
1075impl Lang for LangPtBr {
1076    /// This language's own `to_currency(currency=...)` default,
1077    /// read from the live Python signature. Only 44 of 156 use EUR.
1078    fn default_currency(&self) -> &str {
1079        "BRL"
1080    }
1081
1082    /// This language's own `to_currency(separator=...)` default,
1083    /// read from the live Python signature. Base's is ",", but only
1084    /// 36 of 149 languages actually use it — most default to " " or a
1085    /// conjunction, so inheriting Base's comma silently corrupts them.
1086    fn default_separator(&self) -> &str {
1087        " e"
1088    }
1089
1090    fn cards(&self) -> &Cards {
1091        &self.cards
1092    }
1093    fn maxval(&self) -> &BigInt {
1094        &self.maxval
1095    }
1096    fn negword(&self) -> &str {
1097        // PT.setup, the PT_BR class attribute and PT_BR.__init__ all agree on
1098        // "menos " — note the trailing space, which PT_BR's to_cardinal relies
1099        // on ("%s%s" % (negword, ...)) rather than re-adding one.
1100        "menos "
1101    }
1102    fn pointword(&self) -> &str {
1103        "vírgula"
1104    }
1105    fn exclude_title(&self) -> &[String] {
1106        &self.exclude_title
1107    }
1108
1109    /// `Num2Word_PT_BR.merge`.
1110    ///
1111    /// Diverges from `Num2Word_PT.merge` in four ways, all preserved:
1112    ///   * the "cento" guard is `0 < nnum < 1000` rather than
1113    ///     `nnum % 1000 != 0` (so "cem mil" survives at 100 * 1000);
1114    ///   * the multiplicative test is `nnum > cnum`, so `nnum == cnum` falls
1115    ///     into the additive arm (PT tests `nnum < cnum` and sends equality
1116    ///     the other way). Unreachable from `splitnum`, but not equivalent.
1117    ///   * there is no `10^9` / "liões" rung, only the `10^6` / "lhões" one;
1118    ///   * PT's `if ntext == "milião": ntext = "milhão"` repair is **absent**
1119    ///     — see quirk 1 in the module docs.
1120    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
1121        let (ctext, cnum) = l;
1122        let (ntext, nnum) = r;
1123        let mut ctext = ctext.to_string();
1124        let mut ntext = ntext.to_string();
1125
1126        let hundred = BigInt::from(100);
1127        let thousand = BigInt::from(1000);
1128        let million = BigInt::from(1_000_000);
1129
1130        if cnum.is_one() {
1131            if nnum < &million {
1132                return (ntext, cnum * nnum);
1133            }
1134            ctext = "um".to_string();
1135        } else if cnum == &hundred && nnum > &BigInt::zero() && nnum < &thousand {
1136            // In Brazilian Portuguese, 100 + any number up to 999 becomes
1137            // "cento". But 100 * 1000 = 100000 stays as "cem mil".
1138            ctext = "cento".to_string();
1139        }
1140
1141        if nnum > cnum {
1142            // Multiplicative case (e.g. "dois mil").
1143            if (nnum % &million).is_zero() && cnum > &BigInt::one() {
1144                if nnum == &million {
1145                    ntext = "milhões".to_string();
1146                } else {
1147                    // Python `ntext[:-4] + "lhões"` — slice by character, and
1148                    // tolerate strings shorter than 4 the way Python does.
1149                    // Unreachable: nnum >= 10^12 never reaches merge, because
1150                    // to_cardinal never hands super() a value >= 10^9.
1151                    let chars: Vec<char> = ntext.chars().collect();
1152                    let keep: String = chars[..chars.len().saturating_sub(4)].iter().collect();
1153                    ntext = format!("{}lhões", keep);
1154                }
1155            }
1156
1157            if nnum == &hundred {
1158                ctext = self.hundreds_word(cnum).to_string();
1159                ntext = String::new();
1160            } else {
1161                ntext = format!(" {}", ntext);
1162            }
1163
1164            (format!("{}{}", ctext, ntext), cnum * nnum)
1165        } else {
1166            // Additive case (e.g. "vinte e dois").
1167            (format!("{} e {}", ctext, ntext), cnum + nnum)
1168        }
1169    }
1170
1171    /// `Num2Word_PT_BR.to_cardinal`.
1172    ///
1173    /// Brazil is short scale (bilhão = 10^9, trilhão = 10^12); the inherited
1174    /// PT engine is long scale (bilião = 10^12), so PT_BR peels the 10^9 and
1175    /// 10^12 rungs off by hand and only delegates the sub-10^9 tail.
1176    ///
1177    /// Note which helper each branch reaches for — the asymmetry is real:
1178    ///   * trillions branch: count via `self`, tail via `super()`;
1179    ///   * billions branch:  count via `self`, tail via `super()`;
1180    ///   * millions branch:  count via `super()`, tail via `self`;
1181    ///   * below 10^6:       via `super()`, and this is the *only* branch that
1182    ///     falls through to the trailing comma loop — every other branch
1183    ///     returns early (quirk 3).
1184    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1185        // Handle negative numbers. negword already carries its trailing space.
1186        if value.is_negative() {
1187            return Ok(format!("{}{}", self.negword(), self.to_cardinal(&(-value))?));
1188        }
1189
1190        let hundred = BigInt::from(100);
1191        let e5 = BigInt::from(100_000u32);
1192        let e6 = BigInt::from(1_000_000u32);
1193        let e9 = BigInt::from(1_000_000_000u32);
1194        let e12 = BigInt::from(1_000_000_000_000u64);
1195
1196        if value >= &e9 {
1197            // Handle trillions (10^12).
1198            if value >= &e12 {
1199                let (trillions, remainder) = value.div_rem(&e12);
1200                let mut result = if trillions.is_one() {
1201                    "um trilhão".to_string()
1202                } else {
1203                    format!("{} trilhões", self.to_cardinal(&trillions)?)
1204                };
1205
1206                if !remainder.is_zero() {
1207                    if remainder >= e9 {
1208                        // Has billions.
1209                        let (billions, rest) = remainder.div_rem(&e9);
1210                        if billions.is_one() {
1211                            result.push_str(", um bilhão");
1212                        } else {
1213                            result.push_str(&format!(", {} bilhões", self.to_cardinal(&billions)?));
1214                        }
1215                        if !rest.is_zero() {
1216                            let rest_str = fix_milhao(&self.super_to_cardinal(&rest)?);
1217                            result.push_str(&format!(" e {}", rest_str));
1218                        }
1219                    } else {
1220                        // No billions, just add remainder.
1221                        let remainder_str = fix_milhao(&self.super_to_cardinal(&remainder)?);
1222                        result.push_str(&format!(" e {}", remainder_str));
1223                    }
1224                }
1225                return Ok(result);
1226            }
1227
1228            // Handle billions (10^9).
1229            let (billions, remainder) = value.div_rem(&e9);
1230            let mut result = if billions.is_one() {
1231                "um bilhão".to_string()
1232            } else {
1233                format!("{} bilhões", self.to_cardinal(&billions)?)
1234            };
1235
1236            if !remainder.is_zero() {
1237                let remainder_str = fix_milhao(&self.super_to_cardinal(&remainder)?);
1238                // Comma if the remainder starts with hundreds (>= 100000).
1239                if remainder >= e5 {
1240                    result.push_str(&format!(", {}", remainder_str));
1241                } else {
1242                    result.push_str(&format!(" e {}", remainder_str));
1243                }
1244            }
1245            return Ok(result);
1246        }
1247
1248        // For values below 1 billion but above a million, handle specially.
1249        if value >= &e6 {
1250            let (millions, remainder) = value.div_rem(&e6);
1251            let mut result = if millions.is_one() {
1252                "um milhão".to_string()
1253            } else {
1254                format!("{} milhões", self.super_to_cardinal(&millions)?)
1255            };
1256
1257            if !remainder.is_zero() {
1258                let remainder_str = self.to_cardinal(&remainder)?;
1259                // Comma if the remainder starts with hundreds (>= 100).
1260                if remainder >= hundred {
1261                    result.push_str(&format!(", {}", remainder_str));
1262                } else {
1263                    result.push_str(&format!(" e {}", remainder_str));
1264                }
1265            }
1266            return Ok(result);
1267        }
1268
1269        // For values below 1 million, use the parent implementation.
1270        let mut result = fix_milhao(&self.super_to_cardinal(value)?);
1271
1272        // Transforms "mil e cento e catorze" into "mil, cento e catorze".
1273        // Reachable only from the branch above; "milhão" and up are dead
1274        // entries here, kept because Python iterates them all.
1275        for ext in [
1276            "mil",
1277            "milhão",
1278            "milhões",
1279            "bilhão",
1280            "bilhões",
1281            "trilhão",
1282            "trilhões",
1283            "quatrilhão",
1284            "quatrilhões",
1285        ] {
1286            if let Some(rewritten) = ptbr_comma(&result, ext) {
1287                result = rewritten;
1288            }
1289        }
1290
1291        Ok(result)
1292    }
1293
1294    /// `Num2Word_PT.to_ordinal` (inherited unchanged; PT_BR only swaps the
1295    /// `thousand_separators` table underneath it).
1296    ///
1297    /// Walks the decimal string right-to-left, emitting a scale word whenever
1298    /// a group boundary carries a non-zero digit — which is what avoids
1299    /// "segundo milionésimo milésimo" for 6000000.
1300    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1301        self.verify_ordinal(value)?;
1302
1303        let s = value.to_string(); // Python: value = str(int(value))
1304        let mut result: Vec<String> = Vec::new();
1305        let mut thousand_separator: &str = "";
1306
1307        for (idx, ch) in s.chars().rev().enumerate() {
1308            if idx != 0 && idx % 3 == 0 {
1309                // PT_BR's table stops at 15 → KeyError from idx 18 (i.e. any
1310                // value with 19+ digits). The lookup happens before the digit
1311                // is examined, so even 10^18 with all-zero groups blows up.
1312                thousand_separator = match self.thousand_separators.get(&idx) {
1313                    Some(t) => *t,
1314                    None => return Err(N2WError::Key(idx.to_string())),
1315                };
1316            }
1317
1318            if ch != '0' && !thousand_separator.is_empty() {
1319                // Avoiding "segundo milionésimo milésimo" for 6000000.
1320                result.push(thousand_separator.to_string());
1321                thousand_separator = "";
1322            }
1323
1324            let d = ch.to_digit(10).unwrap_or(0) as usize;
1325            result.push(ORDS[idx % 3][d].to_string());
1326        }
1327
1328        result.reverse();
1329        let joined = result.join(" ");
1330        let result = collapse_ws(joined.trim());
1331
1332        if result.starts_with("primeiro") && s != "1" {
1333            // Avoiding "primeiro milésimo", "primeiro milionésimo" and so on.
1334            // Python `result[9:]` slices by character and tolerates an index
1335            // past the end (only reachable if result were exactly "primeiro",
1336            // which requires value == 1 and is excluded by the guard).
1337            return Ok(result.chars().skip(9).collect());
1338        }
1339        Ok(result)
1340    }
1341
1342    /// `Num2Word_PT.to_ordinal_num`: `"%sº" % value`, after verify_ordinal.
1343    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1344        self.verify_ordinal(value)?;
1345        Ok(format!("{}º", value))
1346    }
1347
1348    /// `Num2Word_PT.to_year`. Dispatches through `self`, so it picks up
1349    /// PT_BR's short-scale `to_cardinal`.
1350    fn to_year(&self, value: &BigInt) -> Result<String> {
1351        if value.is_negative() {
1352            return Ok(format!("{} antes de Cristo", self.to_cardinal(&value.abs())?));
1353        }
1354        self.to_cardinal(value)
1355    }
1356
1357    // ---- float/Decimal entry points --------------------------------------
1358    //
1359    // `cardinal_float_entry` stays at the trait default: PT_BR's Python
1360    // `to_cardinal(float)` peels the short-scale rungs in the float domain,
1361    // but for a *whole* value every divmod is exact and the result is
1362    // identical to the int path, so the default's whole -> int routing is
1363    // faithful; fractional values land in `to_cardinal_float` above.
1364
1365    /// `Num2Word_PT.to_ordinal` (inherited) on float/Decimal input.
1366    ///
1367    /// `value = int(value)` — truncation toward zero — runs *before*
1368    /// `verify_ordinal`, so `2.5` -> "segundo", `0.5` -> "", `-0.0` -> "",
1369    /// and `-1.5` raises the negative-num TypeError (never the float one).
1370    /// 10^18 and up still hit the `thousand_separators` KeyError.
1371    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1372        self.to_ordinal(&float_trunc_int(value)?)
1373    }
1374
1375    /// `Num2Word_PT.to_ordinal_num` (inherited) on float/Decimal input:
1376    /// `verify_ordinal(value)` on the **raw** value, then `"%sº" % value`.
1377    ///
1378    /// Check order is Python's: the float test comes first, so `-1.5` raises
1379    /// `errmsg_floatord`, not `errmsg_negord`. Both messages interpolate
1380    /// `str(value)` — the binding's `repr_str`. The negative test is numeric:
1381    /// `-0.0` passes (abs(-0.0) == -0.0) and yields "-0.0º".
1382    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
1383        match value.as_whole_int() {
1384            None => Err(N2WError::Type(format!(
1385                "Cannot treat float {} as ordinal.",
1386                repr_str
1387            ))),
1388            Some(i) if i.is_negative() => Err(N2WError::Type(format!(
1389                "Cannot treat negative num {} as ordinal.",
1390                repr_str
1391            ))),
1392            Some(_) => Ok(format!("{}º", repr_str)),
1393        }
1394    }
1395
1396    /// `Num2Word_PT.to_year` (inherited) on float/Decimal input. `val < 0` is
1397    /// numeric (-0.0 goes positive); the negative branch renders
1398    /// `to_cardinal(abs(val)) + " antes de Cristo"` — fractional floats
1399    /// through PT_BR's own float grammar: `to_year(-1.5)` ==
1400    /// "um vírgula cinco antes de Cristo".
1401    ///
1402    /// **Fractional `Decimal`s render with the pending "ponto"**, served
1403    /// natively. A fractional Decimal reaching year mode came from a dotted
1404    /// US-style *string* ("1.5") whose `str_to_number` stashed
1405    /// `_pending_pointword = "ponto"`: the binding threads that stash only to
1406    /// cardinal mode (`ParsedNumber::DecPoint` → `cardinal_with_pointword`),
1407    /// dropping it for year, so year_float_entry must reapply "ponto" itself.
1408    /// Every fractional value that arrives here as a `FloatValue::Decimal` is
1409    /// string-origin (a genuine `Decimal` fraction is not exercised in year
1410    /// mode; a genuine `float` fraction arrives as `FloatValue::Float` and
1411    /// keeps the default "vírgula"), so "ponto" is the right pointword for the
1412    /// whole Decimal arm — `to_year("1.5")` == "um ponto cinco".
1413    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1414        let fractional_decimal =
1415            matches!(value, FloatValue::Decimal { .. }) && value.as_whole_int().is_none();
1416        // year delegates to to_cardinal; a fractional Decimal keeps "ponto",
1417        // everything else the instance default "vírgula".
1418        let render = |v: &FloatValue| -> Result<String> {
1419            if fractional_decimal {
1420                self.cardinal_float_with_pointword(v, "ponto")
1421            } else {
1422                self.cardinal_float_entry(v, None)
1423            }
1424        };
1425        if fv_numeric_negative(value) {
1426            return Ok(format!("{} antes de Cristo", render(&fv_abs(value))?));
1427        }
1428        render(value)
1429    }
1430
1431    // ---- string inputs ----------------------------------------------------
1432
1433    /// `Num2Word_PT_BR.str_to_number` (issue #63 ports
1434    /// savoirfairelinux/num2words#300).
1435    ///
1436    /// Brazilian Portuguese uses ',' as the decimal separator; a string
1437    /// written with '.' and no ',' is US-style and is pronounced "ponto"
1438    /// instead of "vírgula". Python stashes `_pending_pointword = "ponto"` on
1439    /// the instance and `to_cardinal_float` consumes it; here the handshake
1440    /// rides in `ParsedNumber::DecPoint`, consumed by
1441    /// [`Lang::cardinal_with_pointword`].
1442    ///
1443    /// "Infinity"/"NaN" parse fine as `Decimal`, but PT_BR's own
1444    /// `to_cardinal` then raises `decimal.InvalidOperation` — the `>= 10^9`
1445    /// branch runs `divmod(value, 10**12)` on Infinity, and NaN blows up
1446    /// even earlier, on the `value < 0` ordering comparison — instead of the
1447    /// base OverflowError/ValueError the binding's Inf/NaN arms model. Mapped
1448    /// here because str_to_number is the only per-language interception
1449    /// point; the corpus pins the cardinal rows (`InvalidOperation` for all
1450    /// three), and year/currency agree (`< 0` / `% 1` raise the same way).
1451    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1452        // Python: s = str(value).strip(); "." in s and "," not in s
1453        let stripped = s.trim();
1454        let us_style = stripped.contains('.') && !stripped.contains(',');
1455        match python_decimal_parse(s)? {
1456            ParsedNumber::Dec(value) if us_style => Ok(ParsedNumber::DecPoint {
1457                value,
1458                pointword: "ponto",
1459            }),
1460            ParsedNumber::Inf { .. } | ParsedNumber::NaN => Err(N2WError::Custom {
1461                module: "decimal",
1462                class: "InvalidOperation",
1463                msg: "[<class 'decimal.InvalidOperation'>]".into(),
1464            }),
1465            other => Ok(other),
1466        }
1467    }
1468
1469    // ---- fractions ---------------------------------------------------------
1470
1471    /// `Num2Word_PT.to_fraction`, inherited unchanged — but `self.to_ordinal`
1472    /// and `self.to_cardinal` dispatch to PT_BR's own (short-scale) methods.
1473    ///
1474    /// Deviations from the Base default, all transcribed:
1475    ///   * idiomatic halves/thirds: "meio"/"meios", "terço"/"terços";
1476    ///   * the "s" plural is appended only when the ordinal does not already
1477    ///     end in "s" (Base appends unconditionally);
1478    ///   * `abs_n == 1` short-circuits the numerator to the literal "um".
1479    /// `denominator == 1` / `numerator == 0` return the *signed* cardinal,
1480    /// before any of that. `self.to_ordinal(abs_d)` can still raise (KeyError
1481    /// at 10^18+), which propagates exactly as in Python.
1482    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
1483        if denominator.is_zero() {
1484            return Err(N2WError::ZeroDivision(
1485                "denominator must not be zero".into(),
1486            ));
1487        }
1488        if denominator.is_one() || numerator.is_zero() {
1489            return self.to_cardinal(numerator);
1490        }
1491        let is_negative = numerator.is_negative() ^ denominator.is_negative();
1492        let abs_n = numerator.abs();
1493        let abs_d = denominator.abs();
1494
1495        let den_word = if abs_d == BigInt::from(2) {
1496            if abs_n.is_one() { "meio" } else { "meios" }.to_string()
1497        } else if abs_d == BigInt::from(3) {
1498            if abs_n.is_one() { "terço" } else { "terços" }.to_string()
1499        } else {
1500            let mut w = self.to_ordinal(&abs_d)?;
1501            if !abs_n.is_one() && !w.ends_with('s') {
1502                w.push('s');
1503            }
1504            w
1505        };
1506        let num_word = if abs_n.is_one() {
1507            "um".to_string()
1508        } else {
1509            self.to_cardinal(&abs_n)?
1510        };
1511        let sign = if is_negative {
1512            format!("{} ", self.negword().trim())
1513        } else {
1514            String::new()
1515        };
1516        Ok(format!("{}{} {}", sign, num_word, den_word))
1517    }
1518
1519    /// `Num2Word_PT_BR.to_cardinal_float`.
1520    ///
1521    /// The corpus float/Decimal rows do **not** enter the base float path the
1522    /// way the 26 inheriting languages do. Python dispatches a non-integer to
1523    /// `Num2Word_PT_BR.to_cardinal(value)`, whose short-scale wrapper peels the
1524    /// 10^12 / 10^9 / 10^6 rungs off the *whole value* (fraction included) with
1525    /// `divmod`, and only the lowest group reaches the base
1526    /// `to_cardinal_float`. That routing renders the low group through
1527    /// `self.to_cardinal` (the millions branch, with its comma) rather than the
1528    /// base engine, so `default_to_cardinal_float` — which would call
1529    /// `to_cardinal(pre)` on the *entire* integer part and route the low group
1530    /// through the base engine — drops a comma:
1531    ///
1532    /// ```text
1533    /// Decimal("98746251323029.99")
1534    ///   default_to_cardinal_float: "… um milhões trezentos …"   (WRONG)
1535    ///   Num2Word_PT_BR.to_cardinal: "… um milhões, trezentos …" (right)
1536    /// ```
1537    ///
1538    /// So this override reproduces `Num2Word_PT_BR.to_cardinal` for a
1539    /// non-integer via [`LangPtBr::frac_cardinal`], which carries the `divmod`
1540    /// in the value's own domain so the f64 artefacts survive. The sign is
1541    /// peeled here, matching the Python `"%s%s" % (negword, …)` wrapper
1542    /// (`base.to_cardinal_float`'s own `pre == 0` negword branch is therefore
1543    /// dead for PT_BR and is not reproduced).
1544    ///
1545    /// `precision_override` is deliberately ignored: `Num2Word_PT_BR`'s Python
1546    /// override drops the `precision=` kwarg, and `base.float2tuple` resets the
1547    /// per-instance precision from `repr` regardless, so the live interpreter
1548    /// ignores it too (`num2words(12.34, lang='pt_BR', precision=1)` still
1549    /// yields two fractional digits).
1550    fn to_cardinal_float(
1551        &self,
1552        value: &FloatValue,
1553        _precision_override: Option<u32>,
1554    ) -> Result<String> {
1555        // No pending 'ponto' on this entry: a direct float/Decimal never went
1556        // through str_to_number, so the pointword is the instance default.
1557        self.cardinal_float_with_pointword(value, self.pointword())
1558    }
1559
1560    /// `Num2Word_PT_BR.str_to_number` + `to_cardinal_float`'s pointword swap,
1561    /// consumed as one call (issue #63, savoirfairelinux/num2words#300).
1562    ///
1563    /// The binding routes `ParsedNumber::DecPoint` here for `to='cardinal'`.
1564    /// Python's `to_cardinal(Decimal)` first routes wholeness: a whole value
1565    /// ("12.") takes the int path and the pending "ponto" is never read, so
1566    /// `num2words("12.", lang='pt_BR')` is "doze"; a fractional one reaches
1567    /// `to_cardinal_float`, which swaps `self.pointword` to "ponto" for the
1568    /// duration of the call: ".5" -> "zero ponto cinco".
1569    fn cardinal_with_pointword(
1570        &self,
1571        value: &FloatValue,
1572        pointword: &str,
1573        _precision_override: Option<u32>,
1574    ) -> Result<String> {
1575        if let Some(i) = value.as_whole_int() {
1576            return self.to_cardinal(&i);
1577        }
1578        self.cardinal_float_with_pointword(value, pointword)
1579    }
1580
1581    // ---- currency -------------------------------------------------------
1582    //
1583    // PT_BR inherits `to_cheque`, `_money_verbose`, `_cents_verbose` and
1584    // `_cents_terse` from `Num2Word_Base` and `pluralize` from
1585    // `Num2Word_EUR`; all four Base methods are exactly the trait defaults, so
1586    // only `pluralize`, the two data tables, the class name and the rewritten
1587    // `to_currency` appear here. `currency_precision` is *not* overridden: see
1588    // the note in `new()`.
1589
1590    fn lang_name(&self) -> &str {
1591        "Num2Word_PT_BR"
1592    }
1593
1594    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1595        self.currency_forms.get(code)
1596    }
1597
1598    fn currency_adjective(&self, code: &str) -> Option<&str> {
1599        self.currency_adjectives.get(code).copied()
1600    }
1601
1602    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
1603    ///
1604    /// Python indexes the tuple directly, so a one-form entry with `n != 1`
1605    /// raises IndexError. Every entry in PT_BR's table has exactly two forms,
1606    /// so this is unreachable — but it is mapped to `Index` rather than
1607    /// panicking so the exception type survives if the table ever changes.
1608    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1609        let form = if n.is_one() { 0 } else { 1 };
1610        forms
1611            .get(form)
1612            .cloned()
1613            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1614    }
1615
1616    /// `Num2Word_PT_BR.to_currency`.
1617    ///
1618    /// Two steps of its own around a `super()` call into
1619    /// [`LangPtBr::pt_to_currency`]: demote whole floats/Decimals to `int`
1620    /// first (quirk 8), then re-run the `" de"` insertion with the Brazilian
1621    /// scale words that PT's European list cannot match (quirk 9).
1622    fn to_currency(
1623        &self,
1624        val: &CurrencyValue,
1625        currency: &str,
1626        cents: bool,
1627        separator: Option<&str>,
1628        adjective: bool,
1629    ) -> Result<String> {
1630        // The trait hands us None when the caller omitted `separator=`;
1631        // resolve it to this language's own default (" e") before the body.
1632        let separator = separator.unwrap_or(self.default_separator());
1633
1634        // if isinstance(val, Decimal) and val % 1 == 0:  val = int(val)
1635        // elif isinstance(val, float) and val == int(val): val = int(val)
1636        //
1637        // `CurrencyValue::Decimal` covers both Python types, and both tests
1638        // reduce to the same predicate — "has no fractional part". The
1639        // truncating `int()` and the sign-of-dividend `Decimal % 1` only
1640        // differ from a floor on *non*-integral input, which neither arm
1641        // accepts. So one `is_integer()` check serves both.
1642        //
1643        // This is quirk 8: it discards `has_decimal` and pushes 1.0 onto the
1644        // no-cents integer path, so the branch must come first, exactly here.
1645        let val: CurrencyValue = match val {
1646            CurrencyValue::Decimal { value, .. } if value.is_integer() => {
1647                CurrencyValue::Int(value.with_scale(0).as_bigint_and_exponent().0)
1648            }
1649            other => other.clone(),
1650        };
1651
1652        // Use parent class implementation with our currency forms.
1653        let mut result = self.pt_to_currency(&val, currency, cents, separator, adjective)?;
1654
1655        // For Brazilian Portuguese we need to add "de" after
1656        // millions/billions/trillions when they are round numbers.
1657        if let CurrencyValue::Int(v) = &val {
1658            // try: cr1, cr2 = self.CURRENCY_FORMS[currency] / except KeyError: pass
1659            if let Some(forms) = self.currency_forms.get(currency) {
1660                let cr1 = &forms.unit;
1661                // cr1[1] if abs(val) != 1 and len(cr1) > 1 else cr1[0]
1662                let abs_val = v.abs();
1663                let currency_str = if !abs_val.is_one() && cr1.len() > 1 {
1664                    cr1.get(1)
1665                } else {
1666                    cr1.first()
1667                }
1668                .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?
1669                .clone();
1670
1671                for ext in PT_BR_DE_EXTS {
1672                    if let Some(rewritten) = insert_de(&result, ext, &currency_str) {
1673                        result = rewritten;
1674                    }
1675                }
1676            }
1677        }
1678
1679        // Note: no `.strip()` here, unlike PT's integer branch.
1680        Ok(result)
1681    }
1682}