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, ¤cy_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, ¤cy_str) {
1673 result = rewritten;
1674 }
1675 }
1676 }
1677 }
1678
1679 // Note: no `.strip()` here, unlike PT's integer branch.
1680 Ok(result)
1681 }
1682}