num2words2_core/lang_br.rs
1//! Port of `lang_BR.py` (Breton).
2//!
3//! Registry check: `__init__.py` maps `"br"` → `lang_BR.Num2Word_BR()`, so this
4//! file ports `Num2Word_BR` — the class the key actually resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_BR` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the `__init__`
8//! guard
9//!
10//! ```python
11//! if any(hasattr(self, f) for f in ["high_numwords", "mid_numwords", "low_numwords"]):
12//! ```
13//!
14//! never fires: Python builds **no** `self.cards` and sets **no** `self.MAXVAL`
15//! (verified: `hasattr(b, "cards") is False`, `hasattr(b, "MAXVAL") is False`).
16//! `to_cardinal` is overridden outright and drives `_int_to_word`, so
17//! `cards`/`maxval`/`merge` stay at their trait defaults here and **no overflow
18//! check exists** — there is no input this module rejects.
19//!
20//! The Python inheritance chain is exactly `Num2Word_BR` → `Num2Word_Base` →
21//! `object`. Every in-scope method is overridden by BR, so nothing is inherited
22//! for the four supported modes:
23//! * `to_cardinal` — overridden (below)
24//! * `to_ordinal` — overridden: cardinal + `"-vet"`
25//! * `to_ordinal_num` — overridden: `str(number) + "."`
26//! * `to_year` — overridden: delegates to `to_cardinal`, ignoring the
27//! `longval` keyword entirely
28//!
29//! # The float / Decimal path
30//!
31//! BR does **not** override `to_cardinal_float`; it overrides `to_cardinal`,
32//! whose very first line is `n = str(number).strip()`. Every float and every
33//! `Decimal` therefore reaches the *same* body as an int and is dispatched on
34//! whether that string contains a `"."`. `Num2Word_Base.to_cardinal_float` and
35//! `Num2Word_Base.float2tuple` are inherited but **dead**: BR's `to_cardinal`
36//! never calls them, so [`crate::floatpath`] is deliberately not used here.
37//!
38//! That single fact drives everything below. The digits BR speaks are the
39//! digits `repr()` prints — not the ones `float2tuple` derives:
40//!
41//! ```python
42//! if "." in n:
43//! left, right = n.split(".", 1)
44//! ret += self._int_to_word(int(left)) + " " + self.pointword + " "
45//! for digit in right:
46//! ret += self._int_to_word(int(digit)) + " "
47//! return ret.strip()
48//! ```
49//!
50//! ## Further Python quirks reproduced (float / Decimal)
51//!
52//! 14. **`float2tuple`'s artefact heuristic never runs, and neither does its
53//! rounding.** The `< 0.01` rescue in `base.float2tuple` — the thing that
54//! turns `2.675`'s `674.9999999999998` back into `675` — sits on a code
55//! path BR cannot reach; BR just reads `repr(2.675) == "2.675"` and spells
56//! the characters. There is no `round()` anywhere in this file, so the
57//! banker's-rounding trap does not apply to it either.
58//!
59//! The two routes agree on the corpus's headline cases (`1.005` and `2.675`
60//! both come out `"… pemp"`, base by rescuing its artefact and BR by never
61//! creating one), but they are not interchangeable in general: over 30k
62//! random floats they disagree on **8798**. The cheapest witness is a long
63//! fraction, where `post = abs(value - pre) * 10**precision` is evaluated
64//! in f64 and loses the last digit that `repr` kept —
65//! `0.17108284528077355` has `precision == 17`, and that product is
66//! `1.7108284528077356e16`, so `float2tuple` reports `…356` where `repr`
67//! says `…355`. Base ends `"… pemp c'hwec'h"` (6); BR ends `"… pemp pemp"`
68//! (5). `num2words(0.17108284528077355, lang="br")` returns BR's, which is
69//! what makes the string route the observable one and not a detail.
70//! 15. **`self.precision` is never read, so `precision=` does nothing.**
71//! `Num2Word_Base.__init__` sets `self.precision = 2` and the dispatcher
72//! happily assigns to it (`hasattr(converter, "precision")` is True), but
73//! BR's `to_cardinal` spells *every* character of `right` regardless.
74//! Verified against the interpreter: `num2words(1.2345, lang="br",
75//! precision=2)` == `num2words(1.2345, lang="br", precision=8)` ==
76//! `"unan point daou tri pevar pemp"` — all four digits, both times. Hence
77//! `precision_override` is accepted and ignored below.
78//! 16. **Scientific notation is a `ValueError`, and *which* one depends on the
79//! digit count.** `repr` switches to exponent form at `decpt <= -4` or
80//! `decpt > 16`, and neither `int()` call in the body tolerates it:
81//! * one significant digit (`repr(1e16) == "1e+16"`) has no `"."`, so the
82//! integer branch runs and `int("1e+16")` raises
83//! `invalid literal for int() with base 10: '1e+16'`;
84//! * more than one (`repr(1.5e16) == "1.5e+16"`) *does* have a `"."`, so
85//! the float branch runs, `int("1")` succeeds, the digit loop eats
86//! `"5"`, and then `int("e")` raises
87//! `invalid literal for int() with base 10: 'e'`.
88//! `inf` and `nan` die the same way as the first case. So BR converts no
89//! float at or beyond `1e16`, and none below `1e-4`.
90//! 17. **A `Decimal` keeps its trailing zeros, because `str` does.**
91//! `str(Decimal("1.10")) == "1.10"`, so 1.10 is `"unan point unan zero"` —
92//! two fraction words for a value a float would render with one. Likewise
93//! `Decimal("5.00")` is `"pemp point zero zero"` while `Decimal("5")`, whose
94//! `str` has no `"."` at all, takes the *integer* branch and is plain
95//! `"pemp"`. This is why the value cannot be normalised on the way in.
96//! 18. **`Decimal`'s exponent threshold is not `float`'s.** `Decimal.__str__`
97//! goes scientific at `_exp > 0` or `adjusted exponent < -6`, and writes the
98//! exponent unpadded with a capital `E`: `str(Decimal("1e-7")) == "1E-7"`
99//! (versus `repr(1e-07) == "1e-07"`), and `str(Decimal("1e-5")) ==
100//! "0.00001"` where the float renders `"1e-05"` and raises. So `Decimal`
101//! spells fractions four orders of magnitude smaller than `float` can.
102//! 19. **`98746251323029.99` survives as a `Decimal` and would not as a float**
103//! (issue #603). It renders `"98746251323029 point nav nav"` — the integer
104//! part going through `_int_to_word`'s 10^9 digit fallback (quirk 1).
105//!
106//! # Faithfully reproduced Python quirks
107//!
108//! This is a port, not a rewrite. All of the following are odd-looking but are
109//! exactly what Python emits, verified against the interpreter:
110//!
111//! 1. **`_int_to_word` falls back to digits at 10^9.** The if/elif chain stops
112//! at `number < 1000000000` and the final `else` is
113//! `return str(number) # Fallback for very large numbers`. So
114//! `to_cardinal(10**9)` == `"1000000000"` — bare digits, not words — and
115//! `to_ordinal(10**9)` == `"1000000000-vet"`. The negative sign is stripped
116//! by `to_cardinal` before `_int_to_word` runs, so
117//! `to_cardinal(-10**9)` == `"minus 1000000000"`. This is why the fallback
118//! must use arbitrary-precision `to_string()`: the corpus reaches 10^21,
119//! well past `u64`. Confirmed by corpus rows for 10^9, 10^12, 10^18, 10^21.
120//! 2. **Zero is spelled by an accident of falsiness.** `_int_to_word(0)` reads
121//! `return self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is the empty
122//! string, which is falsy in Python, so the ternary *always* takes the else
123//! branch and the first operand is dead. Result: `"zero"`.
124//! 3. **No `verify_ordinal` call.** `Num2Word_Base.verify_ordinal` would raise
125//! `TypeError` for negative ordinals, but BR's `to_ordinal` never calls it.
126//! So `to_ordinal(-1)` == `"minus unan-vet"` rather than raising — the
127//! suffix lands on the end of the whole phrase, not on the number word.
128//! Likewise `to_ordinal(-100)` == `"minus unan kant-vet"`.
129//! 4. **Hundreds are never bare.** The `number < 1000` branch always emits
130//! `self.ones[hundreds_val] + " " + self.hundred`, so 100 is `"unan kant"`
131//! ("one hundred"), never `"kant"`. Same for `mil` and `milion` via the
132//! recursive calls: 1000 is `"unan mil"`, 10^6 is `"unan milion"`.
133//! 5. **Breton's vigesimal tens are stored whole**, spaces and hyphens
134//! included: `tens[7]` is `"dek ha tri-ugent"` (70) and `tens[9]` is
135//! `"dek ha pevar-ugent"` (90). Concatenation is therefore blind to word
136//! count — 99 becomes `"dek ha pevar-ugent nav"`.
137//! 6. **`negword` keeps its trailing space.** `setup` sets `"minus "` (unlike
138//! `Num2Word_Base.to_cardinal`, which does `self.negword.strip()`). BR's
139//! `to_cardinal` prepends it raw and `.strip()`s the finished string, so the
140//! single space survives between sign and number and nowhere else.
141//! 7. **`_int_to_word`'s own negative branch is dead code** on every in-scope
142//! path: `to_cardinal` strips the `"-"` at the string level and feeds
143//! `int(n)` a non-negative value. It is only reachable from `to_currency`
144//! (out of scope). Reproduced anyway in [`int_to_word`] for fidelity.
145//!
146//! # Errors
147//!
148//! For **integer** input: none. BR raises nothing in the four supported modes:
149//! no `MAXVAL` means no `OverflowError`, no `verify_ordinal` means no
150//! `TypeError`, and every table index is bounded by an arithmetic guard.
151//! Accordingly every integer method here returns `Ok`, and the frozen corpus
152//! agrees — all 324 in-scope `br` rows carry `"ok": true`.
153//!
154//! For **float / Decimal** input the only error is `ValueError`, raised by
155//! `int()` on a token it cannot parse (quirk 16), and it is always
156//! [`N2WError::Value`]. There is still no `OverflowError` — `1e16` fails
157//! because `repr` wrote an `e`, not because BR checked a bound.
158//!
159//! # The currency surface
160//!
161//! `Num2Word_BR` defines `CURRENCY_FORMS` (EUR and USD only) and overrides
162//! `to_currency` outright. It defines **no** `CURRENCY_ADJECTIVES`, **no**
163//! `CURRENCY_PRECISION`, and does not override `pluralize`, `_money_verbose`,
164//! `_cents_verbose`, `_cents_terse` or `to_cheque` — all of those stay at
165//! `Num2Word_Base`, which is exactly what the trait defaults already model. So
166//! only [`Lang::lang_name`], [`Lang::currency_forms`] and [`Lang::to_currency`]
167//! are overridden here.
168//!
169//! `to_cheque` is therefore the inherited `Num2Word_Base.to_cheque`, driving
170//! `currency::default_to_cheque`. It looks the code up in `CURRENCY_FORMS`
171//! directly and raises `NotImplementedError` on a miss, which is why
172//! `cheque:EUR`/`cheque:USD` succeed while `cheque:GBP` and friends raise —
173//! matching the corpus exactly. Its `divisor` is
174//! `CURRENCY_PRECISION.get(currency, 100)`, and BR's `CURRENCY_PRECISION` is
175//! the empty inherited dict, so it is **always** 100: `cheque:KWD` would use
176//! `/100`, not `/1000`, if KWD were in the table at all.
177//!
178//! ## Further Python quirks reproduced (currency)
179//!
180//! 8. **An unknown currency code silently becomes euros.** `to_currency` reads
181//! `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
182//! The fallback is the *first inserted* entry — EUR — so `currency="GBP"`,
183//! `"JPY"`, `"KWD"` … all render as euros instead of raising. Verified
184//! against the interpreter: `to_currency(12.34, currency="JPY")` ==
185//! `"dek daou euroioù tregont pevar sentimoù"`. This fallback lives *only*
186//! in `to_currency`; [`Lang::currency_forms`] stays a faithful
187//! `CURRENCY_FORMS[code]` (returning `None` on a miss) so the inherited
188//! `to_cheque` still raises for the same codes.
189//! 9. **`CURRENCY_PRECISION` is never consulted by `to_currency`.** The cents
190//! field is hard-coded to two digits via `parts[1][:2].ljust(2, "0")`, so
191//! 3-decimal currencies get no special handling and the `divisor`
192//! machinery of `base.to_currency` is bypassed entirely. `0.5` is 50
193//! subunits for every code.
194//! 10. **The int/float split is invisible here.** `base.to_currency` branches
195//! on `isinstance(val, int)`; BR does not — it branches on whether
196//! `str(val)` contains a `"."`. The two agree anyway, because a whole
197//! float stringifies with a `"0"` fraction: `1.0` → `parts[1] == "0"` →
198//! `int("00")` → `0`, which is falsy, so `if cents and right:` drops the
199//! segment just as the int path would. Both `1` and `1.0` give
200//! `"unan euro"`. The distinction is still honoured in the value passed in.
201//! 11. **`cents=False` drops the cents segment rather than going terse.**
202//! `if cents and right:` is the only guard; there is no `_cents_terse`
203//! branch, so `cents=False` loses the subunits entirely.
204//! 12. **A float too large to stringify plainly raises `ValueError`.** Python
205//! takes `str(val)`, so `1e16` and above render in scientific notation and
206//! `int("1e+16")` raises. Confirmed against the interpreter:
207//! `to_currency(1e21)` → `ValueError`. `BigDecimal`'s `Display` preserves
208//! the same `1e+21` form, so `BigInt::from_str` fails on exactly the same
209//! inputs and [`N2WError::Value`] reproduces the type natively.
210//! 13. **`_int_to_word`'s digit fallback is reachable from currency.** Unlike
211//! the four integer modes, `to_currency(1234567890.0)` yields
212//! `"1234567890 euroioù"` — bare digits, per quirk 1.
213
214use crate::base::{Lang, N2WError, Result};
215use crate::currency::{CurrencyForms, CurrencyValue};
216use crate::floatpath::FloatValue;
217use crate::strnum::{python_decimal_parse, ParsedNumber};
218use bigdecimal::BigDecimal;
219use num_bigint::BigInt;
220use num_traits::{One, Signed, ToPrimitive, Zero};
221use std::collections::HashMap;
222use std::str::FromStr;
223
224/// `setup`: `self.negword`. Trailing space is intentional — see quirk 6.
225const NEGWORD: &str = "minus ";
226
227/// `setup`: `self.pointword`. Read by the float/Decimal branch of
228/// [`cardinal_from_py_str`], which splices it between the two halves.
229const POINTWORD: &str = "point";
230
231/// `setup`: `self.ones`. Index 0 is the empty string; see quirk 2.
232const ONES: [&str; 10] = [
233 "",
234 "unan",
235 "daou",
236 "tri",
237 "pevar",
238 "pemp",
239 "c'hwec'h",
240 "seizh",
241 "eizh",
242 "nav",
243];
244
245/// `setup`: `self.tens`, indexed by the tens digit. Breton counts in twenties,
246/// so these are multi-word forms stored verbatim; see quirk 5.
247const TENS: [&str; 10] = [
248 "",
249 "dek",
250 "ugent",
251 "tregont",
252 "daou-ugent",
253 "hanter-kant",
254 "tri-ugent",
255 "dek ha tri-ugent",
256 "pevar-ugent",
257 "dek ha pevar-ugent",
258];
259
260/// `Num2Word_BR.to_currency`'s own default `separator=" "`, confirmed against
261/// the interpreter: `Num2Word_BR.to_currency.__defaults__` is
262/// `("EUR", True, " ", False)`.
263const DEFAULT_SEPARATOR: &str = " ";
264
265/// `Num2Word_Base.to_currency`'s default `separator=","`.
266///
267/// The dispatcher (`__init__.py`) sends `kwargs.get("separator", ",")` down the
268/// Rust fast path — *base's* default, not BR's — so a caller who says nothing
269/// about the separator is indistinguishable from one who explicitly asked for
270/// `","`. `to_currency` treats this value as "caller said nothing" and
271/// substitutes [`DEFAULT_SEPARATOR`]; see the note on [`LangBr::to_currency`].
272const BASE_DEFAULT_SEPARATOR: &str = ",";
273
274/// `setup`: `self.hundred`.
275const HUNDRED: &str = "kant";
276/// `setup`: `self.thousand`.
277const THOUSAND: &str = "mil";
278/// `setup`: `self.million`.
279const MILLION: &str = "milion";
280
281/// Index a numword table with a `BigInt` the caller has already bounded to
282/// `0..=9` by an arithmetic guard. The `expect` is unreachable: every call site
283/// sits behind a `< 10`, `/ 10` (of a value `< 100`), or `/ 100` (of a value
284/// `< 1000`) test, so Python's own list indexing could not go out of range
285/// either.
286fn digit(n: &BigInt) -> usize {
287 n.to_usize().expect("guarded to 0..=9 by the caller")
288}
289
290/// Port of `Num2Word_BR._int_to_word`.
291///
292/// Mirrors the Python if/elif chain arm for arm, including the negative branch
293/// that `to_cardinal` can never reach (quirk 7) and the digits fallback above
294/// 10^9 (quirk 1). Stays on `BigInt` throughout: the fallback arm must render
295/// values the corpus takes to 10^21, and narrowing early would lose them.
296pub fn int_to_word(number: &BigInt) -> String {
297 // Python: `return self.ones[0] if self.ones[0] else "zero"` — ones[0] is
298 // "", which is falsy, so the else branch always wins. See quirk 2.
299 if number.is_zero() {
300 return "zero".to_string();
301 }
302
303 // Dead on every in-scope path (quirk 7), kept for fidelity.
304 if number.is_negative() {
305 return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
306 }
307
308 // From here `number` is strictly positive, so BigInt's truncating `/` and
309 // `%` agree with Python's floor-division `//` and `%`.
310 if *number < BigInt::from(10u32) {
311 return ONES[digit(number)].to_string();
312 }
313
314 if *number < BigInt::from(100u32) {
315 let tens_val = number / 10u32;
316 let ones_val = number % 10u32;
317 if ones_val.is_zero() {
318 return TENS[digit(&tens_val)].to_string();
319 }
320 return format!("{} {}", TENS[digit(&tens_val)], ONES[digit(&ones_val)]);
321 }
322
323 if *number < BigInt::from(1_000u32) {
324 // Python reads self.ones[hundreds_val] directly here rather than
325 // recursing, so 100 is always "unan kant" — never bare "kant".
326 let hundreds_val = number / 100u32;
327 let remainder = number % 100u32;
328 let mut result = format!("{} {}", ONES[digit(&hundreds_val)], HUNDRED);
329 if !remainder.is_zero() {
330 result.push(' ');
331 result.push_str(&int_to_word(&remainder));
332 }
333 return result;
334 }
335
336 if *number < BigInt::from(1_000_000u32) {
337 let thousands_val = number / 1_000u32;
338 let remainder = number % 1_000u32;
339 let mut result = format!("{} {}", int_to_word(&thousands_val), THOUSAND);
340 if !remainder.is_zero() {
341 result.push(' ');
342 result.push_str(&int_to_word(&remainder));
343 }
344 return result;
345 }
346
347 if *number < BigInt::from(1_000_000_000u32) {
348 let millions_val = number / 1_000_000u32;
349 let remainder = number % 1_000_000u32;
350 let mut result = format!("{} {}", int_to_word(&millions_val), MILLION);
351 if !remainder.is_zero() {
352 result.push(' ');
353 result.push_str(&int_to_word(&remainder));
354 }
355 return result;
356 }
357
358 // Python: `return str(number) # Fallback for very large numbers`.
359 // Quirk 1 — bare digits, no words, no error.
360 number.to_string()
361}
362
363/// Python's `int(<str>)`, for the tokens `str(float)`/`str(Decimal)` can hand
364/// it.
365///
366/// The message is reproduced verbatim because it is the *only* thing that
367/// distinguishes BR's two failure modes from each other (quirk 16), and because
368/// `BigInt::from_str`'s own message ("invalid digit found in string") would leak
369/// Rust into a `ValueError` a caller may well be printing.
370///
371/// `BigInt::from_str` is narrower than `int()` in ways this caller cannot
372/// reach: it rejects the surrounding whitespace, `_` separators and non-ASCII
373/// decimal digits that `int()` accepts. Neither `repr(float)` nor
374/// `Decimal.__str__` ever emits any of those, so the two agree on every string
375/// that actually arrives here.
376fn py_int(s: &str) -> Result<BigInt> {
377 BigInt::from_str(s)
378 .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
379}
380
381/// Python's `repr(float)` — i.e. `PyOS_double_to_string(v, 'r', 0,
382/// Py_DTSF_ADD_DOT_0)`, which `float.__str__` is an alias of.
383///
384/// BR spells the characters of this string, so it is the whole specification of
385/// the float path and has to be exact, `".0"` suffix and `"e+16"` threshold
386/// included. It cannot be replaced by `format!("{}", v)`: Rust's `Display`
387/// prints `1.0` as `"1"`, which has no `"."` and would silently route 1.0
388/// through the *integer* branch to `"unan"` instead of `"unan point zero"`.
389///
390/// # Why the digits are taken in two steps
391///
392/// `_Py_dg_dtoa(mode=0)` is not merely "some string that round-trips": it is
393/// the *n*-significant-digit decimal **nearest** to `v`, ties to **even**, with
394/// *n* the shortest length that round-trips. The two halves of that come from
395/// two different Rust formatters, and using only the first is a real bug:
396///
397/// * `{:e}` is shortest-round-trip, so it settles *n* — and on `n` the two
398/// agree, both being minimal-length.
399/// * `{:e}` does **not** settle the digits. When `v` sits exactly halfway
400/// between two *n*-digit decimals both algorithms accept, Gay rounds the last
401/// digit to even and Rust's shortest rounds it up. `f64::from_bits(
402/// 0x4313781b63796605)` is exactly `1370020896463233.25`: Python prints
403/// `"1370020896463233.2"`, `{:e}` yields `…3.3`, and BR would say `"… tri"`
404/// for a value Python calls `"… daou"`. A 200k-value fuzz against the live
405/// interpreter hit this 1462 times.
406/// * `{:.*e}` *is* exact and correctly rounded, and `flt2dec`'s `format_exact`
407/// documents the same tie rule Gay uses ("if the following digits are exactly
408/// half, round to even"). So the digits come from there, at the width `{:e}`
409/// established.
410///
411/// The rest is CPython's `format_float_short` layout logic transcribed.
412fn py_float_str(v: f64) -> String {
413 // CPython prints these from a fixed table, and `repr` drops the sign of a
414 // nan (`repr(-float("nan")) == "nan"`, sign bit or not) while keeping it on
415 // an inf.
416 if v.is_nan() {
417 return "nan".to_string();
418 }
419 if v.is_infinite() {
420 return if v < 0.0 { "-inf" } else { "inf" }.to_string();
421 }
422
423 // `is_sign_negative`, not `v < 0.0`: `_Py_dg_dtoa` reports the sign *bit*,
424 // so `repr(-0.0) == "-0.0"` and BR answers "minus zero point zero". A
425 // `< 0.0` test says false for -0.0 and would drop the minus.
426 let sign = if v.is_sign_negative() { "-" } else { "" };
427
428 // Step 1: the shortest round-trip form, "d[.ddd]e<exp>" — e.g. 0.5 ->
429 // "5e-1", 1.005 -> "1.005e0", 0.0 -> "0e0". Only its *digit count* is
430 // trusted; see the tie discussion above.
431 let shortest = format!("{:e}", v.abs());
432 let ndigits = shortest
433 .split_once('e')
434 .expect("{:e} always emits an exponent marker")
435 .0
436 .chars()
437 .filter(char::is_ascii_digit)
438 .count();
439
440 // Step 2: re-render at exactly that width. `{:.*e}` is the exact,
441 // correctly-rounded, ties-to-even formatter, which is what dtoa mode 0
442 // actually computes. The exponent is re-read from *this* call rather than
443 // carried over, so a hypothetical rounding carry stays self-consistent.
444 // saturating_sub only to keep an impossible ndigits == 0 from underflowing
445 // a usize; `{:e}` always emits at least one mantissa digit.
446 let exact = format!("{:.*e}", ndigits.saturating_sub(1), v.abs());
447 let (mant, exp) = exact
448 .split_once('e')
449 .expect("{:e} always emits an exponent marker");
450 let exp: i32 = exp.parse().expect("{:e} always emits a decimal exponent");
451 let digits: String = mant.chars().filter(|c| *c != '.').collect();
452 let digits_len = digits.chars().count() as i32;
453 // CPython's decpt: the decimal point's position within `digits`.
454 let decpt = exp + 1;
455
456 // format_float_short, `case 'r'`:
457 //
458 // /* Convert to exponential format at 1e16. ... */
459 // if (decpt <= -4 || decpt > 16)
460 // use_exp = 1;
461 if decpt <= -4 || decpt > 16 {
462 // exp = decpt - 1; decpt = 1;
463 let e = decpt - 1;
464 let mut out = String::from(sign);
465 let mut it = digits.chars();
466 out.push(it.next().expect("dtoa never returns an empty digit string"));
467 let rest: String = it.collect();
468 // Py_DTSF_ADD_DOT_0 is not applied in exponent form, so a lone digit
469 // stays lone: repr(1e16) is "1e+16", never "1.0e+16".
470 if !rest.is_empty() {
471 out.push('.');
472 out.push_str(&rest);
473 }
474 // CPython: `sprintf(p, "%+.02d", exp)` — always signed, at least two
475 // digits, never truncated. 16 -> "+16", -5 -> "-05", -324 -> "-324".
476 out.push('e');
477 out.push(if e < 0 { '-' } else { '+' });
478 out.push_str(&format!("{:02}", (e as i64).abs()));
479 out
480 } else if decpt <= 0 {
481 // 0.0001 <= |v| < 1: "0." then -decpt padding zeros, then the digits.
482 format!(
483 "{}0.{}{}",
484 sign,
485 "0".repeat((-decpt) as usize),
486 digits
487 )
488 } else if decpt >= digits_len {
489 // Integral: pad right with zeros, then Py_DTSF_ADD_DOT_0 appends ".0".
490 // This is the arm that makes repr(1.0) == "1.0" and repr(0.0) == "0.0",
491 // and therefore the arm that puts every whole float on the *float*
492 // branch of to_cardinal.
493 format!(
494 "{}{}{}.0",
495 sign,
496 digits,
497 "0".repeat((decpt - digits_len) as usize)
498 )
499 } else {
500 // 0 < decpt < digits_len: split the digit string in place.
501 let head: String = digits.chars().take(decpt as usize).collect();
502 let tail: String = digits.chars().skip(decpt as usize).collect();
503 format!("{}{}.{}", sign, head, tail)
504 }
505}
506
507/// Python's `Decimal.__str__` (the spec's *to-scientific-string*), transcribed
508/// from `_pydecimal.Decimal.__str__` with `eng=False` and the default
509/// `context.capitals == 1`.
510///
511/// Not interchangeable with [`py_float_str`]: the thresholds differ (quirk 18),
512/// the exponent is unpadded and capital-`E`, and — the reason this exists at all
513/// — the coefficient is whatever the caller wrote, so `Decimal("1.10")`
514/// stringifies with its trailing zero intact (quirk 17).
515///
516/// `BigDecimal`'s own `Display` is close but not this: it is free to normalise
517/// and to choose its own exponent threshold, and either divergence changes how
518/// many words BR speaks.
519fn py_decimal_str(d: &BigDecimal) -> String {
520 // BigDecimal is coefficient * 10^-scale; Decimal is _int * 10^_exp.
521 let (coefficient, scale) = d.as_bigint_and_exponent();
522 let exp = -scale; // Decimal._exp
523 let sign = if coefficient.is_negative() { "-" } else { "" };
524 // Decimal._int — the coefficient's digits, unsigned and unnormalised.
525 let int_digits = coefficient.abs().to_string();
526 let len = int_digits.chars().count() as i64;
527
528 // leftdigits = self._exp + len(self._int)
529 let leftdigits = exp + len;
530
531 // if self._exp <= 0 and leftdigits > -6: dotplace = leftdigits
532 // elif not eng: dotplace = 1
533 let dotplace = if exp <= 0 && leftdigits > -6 {
534 leftdigits
535 } else {
536 1
537 };
538
539 let (intpart, fracpart) = if dotplace <= 0 {
540 (
541 "0".to_string(),
542 format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
543 )
544 } else if dotplace >= len {
545 (
546 format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
547 String::new(),
548 )
549 } else {
550 let head: String = int_digits.chars().take(dotplace as usize).collect();
551 let tail: String = int_digits.chars().skip(dotplace as usize).collect();
552 (head, format!(".{}", tail))
553 };
554
555 // exp = ['e', 'E'][context.capitals] + "%+d" % (leftdigits - dotplace)
556 //
557 // "%+d", not the float path's "%+.02d": no zero padding, which is why
558 // str(Decimal("1e-7")) is "1E-7" and repr(1e-07) is "1e-07".
559 let expstr = if leftdigits == dotplace {
560 String::new()
561 } else {
562 let e = leftdigits - dotplace;
563 format!("E{}{}", if e < 0 { "-" } else { "+" }, e.abs())
564 };
565
566 format!("{}{}{}{}", sign, intpart, fracpart, expstr)
567}
568
569/// Port of `Num2Word_BR.to_cardinal`'s body, driven by `str(number)` exactly as
570/// Python drives it.
571///
572/// Python's method takes the *number* and stringifies it on line one; the two
573/// callers here have already done that, because reproducing `str` is where all
574/// the difficulty lives ([`py_float_str`], [`py_decimal_str`]) and it differs
575/// per type. Everything after that line is type-blind, which is precisely why
576/// [`Lang::to_cardinal`] — the integer specialisation of this same body — can
577/// skip the round-trip through text: an integer's `str` has no `"."`, so it
578/// always takes the `else` branch and `int(str(v))` is just `v`.
579fn cardinal_from_py_str(number: &str) -> Result<String> {
580 // n = str(number).strip()
581 let n = number.trim();
582
583 // if n.startswith("-"): n = n[1:]; ret = self.negword
584 // else: ret = ""
585 let (ret, n) = match n.strip_prefix('-') {
586 Some(rest) => (NEGWORD, rest),
587 None => ("", n),
588 };
589
590 // if "." in n: left, right = n.split(".", 1)
591 match n.split_once('.') {
592 Some((left, right)) => {
593 // ret += self._int_to_word(int(left)) + " " + self.pointword + " "
594 let mut out = String::from(ret);
595 out.push_str(&int_to_word(&py_int(left)?));
596 out.push(' ');
597 out.push_str(POINTWORD);
598 out.push(' ');
599
600 // for digit in right: ret += self._int_to_word(int(digit)) + " "
601 //
602 // Iterating a str yields one character at a time, so this is
603 // int() on a single character — and any non-digit character (the
604 // "e" of an exponent form, quirk 16) raises ValueError right here,
605 // after the earlier digits have already been appended and thrown
606 // away with the exception.
607 for digit in right.chars() {
608 let d = py_int(digit.encode_utf8(&mut [0u8; 4]))?;
609 out.push_str(&int_to_word(&d));
610 out.push(' ');
611 }
612
613 // return ret.strip() — this is what removes the loop's last space.
614 Ok(out.trim().to_string())
615 }
616 // return (ret + self._int_to_word(int(n))).strip()
617 None => Ok(format!("{}{}", ret, int_to_word(&py_int(n)?))
618 .trim()
619 .to_string()),
620 }
621}
622
623pub struct LangBr {
624 /// `Num2Word_BR.CURRENCY_FORMS`. Built once in [`LangBr::new`] and only
625 /// read thereafter — the generated registry parks each language in a
626 /// `OnceLock` and calls `new` through `get_or_init`, so this table is
627 /// constructed a single time per process rather than per conversion.
628 currency_forms: HashMap<&'static str, CurrencyForms>,
629 /// `list(self.CURRENCY_FORMS.values())[0]` — the fallback `.get` uses for
630 /// an unknown code (quirk 8).
631 ///
632 /// Python evaluates that expression eagerly on every call and it resolves
633 /// to the *first inserted* entry, which under CPython's insertion-ordered
634 /// dicts is `EUR`. A `HashMap` has no first element, so the choice is
635 /// pinned here rather than left to iteration order.
636 fallback_forms: CurrencyForms,
637}
638
639impl LangBr {
640 pub fn new() -> Self {
641 let mut currency_forms = HashMap::new();
642 // Arity is load-bearing: `to_currency` indexes `cr1[0]`/`cr1[1]`, so
643 // both forms of both tuples must survive verbatim.
644 currency_forms.insert(
645 "EUR",
646 CurrencyForms::new(&["euro", "euroioù"], &["sentim", "sentimoù"]),
647 );
648 currency_forms.insert(
649 "USD",
650 CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
651 );
652 let fallback_forms = currency_forms
653 .get("EUR")
654 .expect("CURRENCY_FORMS[\"EUR\"] is inserted directly above")
655 .clone();
656 LangBr {
657 currency_forms,
658 fallback_forms,
659 }
660 }
661}
662
663impl Default for LangBr {
664 fn default() -> Self {
665 LangBr::new()
666 }
667}
668
669impl Lang for LangBr {
670
671 fn cardinal_float_entry(
672 &self,
673 value: &crate::floatpath::FloatValue,
674 precision_override: Option<u32>,
675 ) -> crate::base::Result<String> {
676 // Python's to_cardinal routes every float/Decimal through this
677 // language's own decimal grammar — 5.0 keeps its ".0" tail
678 // ("comma nulla"), unlike Base's whole-value integer route.
679 self.to_cardinal_float(value, precision_override)
680 }
681
682 /// `to_ordinal(float/Decimal)`: Python's `to_ordinal` is
683 /// `self.to_cardinal(number) + "-vet"` with no verify_ordinal guard, so a
684 /// float keeps its spelled-out ".0" tail: `to_ordinal(5.0)` ==
685 /// "pemp point zero-vet", `to_ordinal(-1.5)` == "minus unan point
686 /// pemp-vet". Exponent-form reprs raise the cardinal path's ValueError.
687 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
688 Ok(format!("{}-vet", self.to_cardinal_float(value, None)?))
689 }
690
691 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — the repr
692 /// verbatim, sign and trailing zeros included: `-0.0` → "-0.0.",
693 /// `Decimal("5.00")` → "5.00.", `1e16` → "1e+16.".
694 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
695 Ok(format!("{}.", repr_str))
696 }
697
698 /// `converter.str_to_number` — base `Decimal(value)` semantics, except
699 /// that an Infinity parse is surfaced as the ValueError BR's own
700 /// `to_cardinal` raises one step later: `str(Decimal("Infinity"))` has no
701 /// "." and `int("Infinity")` chokes on the literal. The dispatcher's
702 /// default maps `ParsedNumber::Inf` to base's OverflowError, which BR can
703 /// never raise. NaN keeps the default routing (ValueError either way).
704 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
705 match python_decimal_parse(s)? {
706 ParsedNumber::Inf { .. } => Err(N2WError::Value(
707 "invalid literal for int() with base 10: 'Infinity'".into(),
708 )),
709 other => Ok(other),
710 }
711 }
712
713 /// This language's own `to_currency(currency=...)` default,
714 /// read from the live Python signature. Only 44 of 156 use EUR.
715 fn default_currency(&self) -> &str {
716 "EUR"
717 }
718
719 /// This language's own `to_currency(separator=...)` default,
720 /// read from the live Python signature. Base's is ",", but only
721 /// 36 of 149 languages actually use it — most default to " " or a
722 /// conjunction, so inheriting Base's comma silently corrupts them.
723 fn default_separator(&self) -> &str {
724 " "
725 }
726
727 fn negword(&self) -> &str {
728 NEGWORD
729 }
730
731 fn pointword(&self) -> &str {
732 "point"
733 }
734
735 /// Port of `Num2Word_BR.to_cardinal` for integral input.
736 ///
737 /// Python works on the *string* form:
738 ///
739 /// ```python
740 /// n = str(number).strip()
741 /// if n.startswith("-"):
742 /// n = n[1:]
743 /// ret = self.negword
744 /// else:
745 /// ret = ""
746 /// # no "." in an integer's repr, so the float branch is skipped
747 /// return (ret + self._int_to_word(int(n))).strip()
748 /// ```
749 ///
750 /// Stripping the leading `"-"` and re-parsing is exactly `abs()`, so the
751 /// value handed to `_int_to_word` is never negative — hence quirk 7. The
752 /// trailing `.strip()` is a no-op for every integer input (`negword`'s
753 /// space always sits between two non-empty tokens), but is kept so the
754 /// shape matches the original.
755 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
756 let (ret, n) = if value.is_negative() {
757 (NEGWORD, value.abs())
758 } else {
759 ("", value.clone())
760 };
761 Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
762 }
763
764 /// The float/Decimal arm of `Num2Word_BR.to_cardinal` — *not* an override
765 /// of `to_cardinal_float`, which BR inherits and never calls.
766 ///
767 /// `num2words(0.5, lang="br")` runs `Num2Word_BR.to_cardinal(0.5)`, which
768 /// stringifies the argument and reads the characters. So the work is all in
769 /// reproducing `str`, and the two arms of [`FloatValue`] need two different
770 /// `str`s — `repr(float)` and `Decimal.__str__` — which is exactly the split
771 /// [`FloatValue`] preserves. Collapsing them here would not merely lose
772 /// precision (issue #603); it would lose *digits BR speaks aloud*, since
773 /// `Decimal("1.10")` is two fraction words and `float(1.10)` is one.
774 ///
775 /// `precision_override` is discarded, because BR never reads
776 /// `self.precision` (quirk 15). The dispatcher does set the attribute — the
777 /// inherited `__init__` defines it — but nothing on this path looks at it.
778 fn to_cardinal_float(
779 &self,
780 value: &FloatValue,
781 _precision_override: Option<u32>,
782 ) -> Result<String> {
783 // Python: `n = str(number).strip()`, where `number` is whatever the
784 // caller passed num2words(). No float() cast, no float2tuple, no
785 // rounding — the sole difference between the arms is `str` itself.
786 let n = match value {
787 FloatValue::Float { value, .. } => py_float_str(*value),
788 FloatValue::Decimal { value, .. } => py_decimal_str(value),
789 };
790 cardinal_from_py_str(&n)
791 }
792
793 /// Port of `Num2Word_BR.to_ordinal`: cardinal + `"-vet"`, with no
794 /// `verify_ordinal` guard (quirk 3). The suffix attaches to the end of the
795 /// whole phrase, so `to_ordinal(-100)` == `"minus unan kant-vet"` and
796 /// `to_ordinal(10**9)` == `"1000000000-vet"`.
797 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
798 Ok(format!("{}-vet", self.to_cardinal(value)?))
799 }
800
801 /// Port of `Num2Word_BR.to_ordinal_num`: `str(number) + "."`. The number is
802 /// never spelled, so the sign survives verbatim: `-1` → `"-1."`.
803 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
804 Ok(format!("{}.", value))
805 }
806
807 /// Port of `Num2Word_BR.to_year`: `return self.to_cardinal(val)`. The
808 /// `longval=True` parameter is accepted and then ignored, so years get no
809 /// special pairing — 1999 is the plain cardinal
810 /// `"unan mil nav kant dek ha pevar-ugent nav"`.
811 fn to_year(&self, value: &BigInt) -> Result<String> {
812 self.to_cardinal(value)
813 }
814
815 // ---- currency ------------------------------------------------------
816 //
817 // Only the three hooks below are overridden. BR defines no
818 // CURRENCY_ADJECTIVES and no CURRENCY_PRECISION, and leaves `pluralize`,
819 // `_money_verbose`, `_cents_verbose`, `_cents_terse` and `to_cheque` at
820 // `Num2Word_Base` — whose behaviour the trait defaults already reproduce.
821
822 /// `self.__class__.__name__`, for the inherited `to_cheque`'s
823 /// `NotImplementedError` message.
824 fn lang_name(&self) -> &str {
825 "Num2Word_BR"
826 }
827
828 /// `CURRENCY_FORMS[code]` — a plain lookup that misses for anything but
829 /// EUR/USD.
830 ///
831 /// Deliberately **not** the `.get(code, <EUR>)` fallback of quirk 8: that
832 /// fallback is local to `to_currency`. The inherited `to_cheque` subscripts
833 /// `CURRENCY_FORMS[currency]` and catches `KeyError`, so it must still see
834 /// a miss here — that is what makes `cheque:GBP` raise
835 /// `NotImplementedError` while `currency:GBP` quietly prints euros.
836 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
837 self.currency_forms.get(code)
838 }
839
840 /// Port of `Num2Word_BR.to_currency`.
841 ///
842 /// BR ignores `base.to_currency` wholesale — no `parse_currency_parts`, no
843 /// `divisor`, no `pluralize`. It slices the decimal *string*:
844 ///
845 /// ```python
846 /// parts = str(val).split(".")
847 /// left = int(parts[0]) if parts[0] else 0
848 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
849 /// ```
850 ///
851 /// # The `separator` argument is not what Python's default would be
852 ///
853 /// BR's own signature defaults to `separator=" "`, but the trait can't
854 /// carry a per-language default argument: the dispatcher resolves it before
855 /// the call and uses `Num2Word_Base`'s `","`. Since the frozen corpus was
856 /// generated through Python — where BR's `" "` applies — `","` is read back
857 /// as the "unset" sentinel it is. This is exact for both a caller who omits
858 /// `separator` and one who passes anything other than `","`, and wrong only
859 /// for an explicit `separator=","`, which Python renders with a comma and
860 /// this renders with a space. See `concerns`; `lang_sa.rs` takes the same
861 /// approach for the same reason.
862 fn to_currency(
863 &self,
864 val: &CurrencyValue,
865 currency: &str,
866 cents: bool,
867 separator: Option<&str>,
868 adjective: bool,
869 ) -> Result<String> {
870 // Trait now hands us None when the caller omitted separator=;
871 // resolve it to this language's own default before the ported body.
872 let separator = separator.unwrap_or(self.default_separator());
873 // Python accepts `adjective` and never reads it: BR has no
874 // CURRENCY_ADJECTIVES and never calls prefix_currency, so
875 // `adjective=True` is byte-identical to the plain call.
876 let _ = adjective;
877
878 let separator = if separator == BASE_DEFAULT_SEPARATOR {
879 DEFAULT_SEPARATOR
880 } else {
881 separator
882 };
883
884 // `if val < 0: is_negative = True; val = abs(val)` happens *before*
885 // `str(val)`, so the string never carries a sign. The abs() is
886 // conditional in Python, so it stays conditional here.
887 let is_negative = val.is_negative();
888 let s = match val {
889 CurrencyValue::Int(v) => {
890 if is_negative {
891 v.abs().to_string()
892 } else {
893 v.to_string()
894 }
895 }
896 CurrencyValue::Decimal { value: d, .. } => {
897 if is_negative {
898 d.abs().to_string()
899 } else {
900 d.to_string()
901 }
902 }
903 };
904
905 // `str(val).split(".")` splits on every dot; Python then reads only
906 // parts[0] and parts[1], so trailing fragments are ignored either way.
907 let mut parts = s.split('.');
908 let part0 = parts.next().unwrap_or("");
909 let part1 = parts.next();
910
911 // `int(parts[0]) if parts[0] else 0`. Evaluated before `right`, so a
912 // scientific-notation string raises here first (quirk 12).
913 let left = if part0.is_empty() {
914 BigInt::zero()
915 } else {
916 BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
917 };
918
919 // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
920 //
921 // `[:2]` truncates and `ljust` pads, so "5" -> "50" (0.5 is 50
922 // subunits) and "01" -> "01" (0.01 is 1). Sliced by chars, not bytes.
923 // A whole float gives "0" -> "00" -> 0, which is falsy (quirk 10).
924 let right = match part1 {
925 Some(f) if !f.is_empty() => {
926 let mut two: String = f.chars().take(2).collect();
927 while two.chars().count() < 2 {
928 two.push('0');
929 }
930 BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
931 }
932 _ => BigInt::zero(),
933 };
934
935 // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — quirk 8.
936 let forms = self
937 .currency_forms
938 .get(currency)
939 .unwrap_or(&self.fallback_forms);
940 let cr1 = &forms.unit;
941 let cr2 = &forms.subunit;
942
943 let one = BigInt::one();
944
945 // `left_str + " " + (cr1[1] if left != 1 else cr1[0])`. Note 0 takes
946 // the plural: "zero euroioù".
947 let mut result = format!(
948 "{} {}",
949 int_to_word(&left),
950 if left != one { &cr1[1] } else { &cr1[0] }
951 );
952
953 // `if cents and right:` — `right` is an int, so 0 is falsy and a float
954 // with zero cents drops the whole segment (quirk 10). `cents=False`
955 // drops it too, with no terse fallback (quirk 11).
956 if cents && !right.is_zero() {
957 result.push_str(separator);
958 result.push_str(&int_to_word(&right));
959 result.push(' ');
960 result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
961 }
962
963 // `result = self.negword + result` — raw, keeping the trailing space of
964 // "minus " (quirk 6).
965 if is_negative {
966 result = format!("{}{}", NEGWORD, result);
967 }
968
969 // `result.strip()`. A no-op for every reachable input, but it is what
970 // Python writes.
971 Ok(result.trim().to_string())
972 }
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978
979 fn f(v: f64) -> Result<String> {
980 LangBr::new().to_cardinal_float(
981 &FloatValue::Float {
982 value: v,
983 // BR never reads precision (quirk 15); any value does.
984 precision: 0,
985 },
986 None,
987 )
988 }
989
990 fn d(s: &str) -> Result<String> {
991 LangBr::new().to_cardinal_float(
992 &FloatValue::Decimal {
993 value: BigDecimal::from_str(s).unwrap(),
994 precision: 0,
995 },
996 None,
997 )
998 }
999
1000 fn ok(r: Result<String>) -> String {
1001 r.expect("expected Ok")
1002 }
1003
1004 fn value_err(r: Result<String>) -> String {
1005 match r {
1006 Err(N2WError::Value(m)) => m,
1007 other => panic!("expected N2WError::Value, got {:?}", other),
1008 }
1009 }
1010
1011 /// Every `"lang": "br", "to": "cardinal"` corpus row whose arg has a dot.
1012 #[test]
1013 fn corpus_float_rows() {
1014 assert_eq!(ok(f(0.0)), "zero point zero");
1015 assert_eq!(ok(f(0.5)), "zero point pemp");
1016 assert_eq!(ok(f(1.0)), "unan point zero");
1017 assert_eq!(ok(f(1.5)), "unan point pemp");
1018 assert_eq!(ok(f(2.25)), "daou point daou pemp");
1019 assert_eq!(ok(f(3.14)), "tri point unan pevar");
1020 assert_eq!(ok(f(0.01)), "zero point zero unan");
1021 assert_eq!(ok(f(0.1)), "zero point unan");
1022 assert_eq!(ok(f(0.99)), "zero point nav nav");
1023 assert_eq!(ok(f(1.01)), "unan point zero unan");
1024 assert_eq!(ok(f(12.34)), "dek daou point tri pevar");
1025 assert_eq!(ok(f(99.99)), "dek ha pevar-ugent nav point nav nav");
1026 assert_eq!(ok(f(100.5)), "unan kant point pemp");
1027 assert_eq!(
1028 ok(f(1234.56)),
1029 "unan mil daou kant tregont pevar point pemp c'hwec'h"
1030 );
1031 assert_eq!(ok(f(-0.5)), "minus zero point pemp");
1032 assert_eq!(ok(f(-1.5)), "minus unan point pemp");
1033 assert_eq!(ok(f(-12.34)), "minus dek daou point tri pevar");
1034 assert_eq!(ok(f(1.005)), "unan point zero zero pemp");
1035 assert_eq!(ok(f(2.675)), "daou point c'hwec'h seizh pemp");
1036 }
1037
1038 /// Every `"lang": "br", "to": "cardinal_dec"` corpus row.
1039 #[test]
1040 fn corpus_decimal_rows() {
1041 assert_eq!(ok(d("0.01")), "zero point zero unan");
1042 assert_eq!(ok(d("1.10")), "unan point unan zero");
1043 assert_eq!(ok(d("12.345")), "dek daou point tri pevar pemp");
1044 assert_eq!(
1045 ok(d("98746251323029.99")),
1046 "98746251323029 point nav nav"
1047 );
1048 assert_eq!(ok(d("0.001")), "zero point zero zero unan");
1049 }
1050
1051 /// Quirk 14: BR reads `repr`, not `float2tuple`, and the two are not
1052 /// interchangeable.
1053 ///
1054 /// `float2tuple(0.17108284528077355)` evaluates `(v - 0) * 10**17` in f64,
1055 /// gets `1.7108284528077356e16` and reports a final digit of 6;
1056 /// `repr` kept the 5. Live check: `num2words(0.17108284528077355,
1057 /// lang="br")` ends "… pemp pemp", i.e. BR's answer, not base's
1058 /// "… pemp c'hwec'h".
1059 #[test]
1060 fn does_not_take_the_float2tuple_route() {
1061 assert_eq!(
1062 ok(f(0.17108284528077355)),
1063 "zero point unan seizh unan zero eizh daou eizh pevar pemp daou \
1064 eizh zero seizh seizh tri pemp pemp"
1065 );
1066 // The corpus's artefact cases, where the two routes happen to agree.
1067 assert_eq!(ok(f(1.005)), "unan point zero zero pemp");
1068 assert_eq!(ok(f(2.675)), "daou point c'hwec'h seizh pemp");
1069 }
1070
1071 /// Quirk 15: `precision=` is inert — BR never reads `self.precision`.
1072 /// Live check: num2words(1.2345, lang="br", precision=2) spells all four.
1073 #[test]
1074 fn precision_override_is_ignored() {
1075 let l = LangBr::new();
1076 let v = FloatValue::Float {
1077 value: 1.2345,
1078 precision: 4,
1079 };
1080 let expected = "unan point daou tri pevar pemp";
1081 assert_eq!(l.to_cardinal_float(&v, None).unwrap(), expected);
1082 assert_eq!(l.to_cardinal_float(&v, Some(2)).unwrap(), expected);
1083 assert_eq!(l.to_cardinal_float(&v, Some(8)).unwrap(), expected);
1084 }
1085
1086 /// `repr(-0.0)` is "-0.0": the sign *bit*, not `v < 0.0`, drives the minus.
1087 #[test]
1088 fn negative_zero_float_keeps_its_sign() {
1089 assert_eq!(ok(f(-0.0)), "minus zero point zero");
1090 assert_eq!(ok(f(0.0)), "zero point zero");
1091 }
1092
1093 /// Quirk 16: scientific notation dies in `int()`, and which `int()` call
1094 /// catches it depends on whether `repr` emitted a ".".
1095 #[test]
1096 fn scientific_notation_raises_value_error() {
1097 // One significant digit -> no "." -> the integer branch's int(n).
1098 assert_eq!(
1099 value_err(f(1e16)),
1100 "invalid literal for int() with base 10: '1e+16'"
1101 );
1102 assert_eq!(
1103 value_err(f(1e21)),
1104 "invalid literal for int() with base 10: '1e+21'"
1105 );
1106 assert_eq!(
1107 value_err(f(1e-5)),
1108 "invalid literal for int() with base 10: '1e-05'"
1109 );
1110 // More than one -> there *is* a "." -> the digit loop's int(digit),
1111 // which trips on the "e" after consuming the mantissa's digits.
1112 assert_eq!(
1113 value_err(f(1.5e16)),
1114 "invalid literal for int() with base 10: 'e'"
1115 );
1116 assert_eq!(
1117 value_err(f(f64::MAX)),
1118 "invalid literal for int() with base 10: 'e'"
1119 );
1120 // inf / nan take the integer branch too.
1121 assert_eq!(
1122 value_err(f(f64::INFINITY)),
1123 "invalid literal for int() with base 10: 'inf'"
1124 );
1125 assert_eq!(
1126 value_err(f(f64::NEG_INFINITY)),
1127 "invalid literal for int() with base 10: 'inf'"
1128 );
1129 assert_eq!(
1130 value_err(f(f64::NAN)),
1131 "invalid literal for int() with base 10: 'nan'"
1132 );
1133 }
1134
1135 /// The repr thresholds are exactly `decpt <= -4` / `decpt > 16`, so these
1136 /// neighbours land on opposite sides of "works" and "raises".
1137 #[test]
1138 fn repr_thresholds() {
1139 // 1e15 is positional, 1e16 is not.
1140 assert_eq!(ok(f(1e15)), "1000000000000000 point zero");
1141 assert!(f(1e16).is_err());
1142 // 1e-4 is positional, 1e-5 is not.
1143 assert_eq!(ok(f(1e-4)), "zero point zero zero zero unan");
1144 assert!(f(1e-5).is_err());
1145 }
1146
1147 /// The tie that `{:e}` alone gets wrong: this double is exactly
1148 /// 1370020896463233.25, equidistant from ...33.2 and ...33.3, and Python
1149 /// prints the even one. Rust's shortest formatter prints ...33.3.
1150 #[test]
1151 fn exact_decimal_ties_round_to_even_like_gay() {
1152 let v = f64::from_bits(0x4313781b63796605);
1153 assert_eq!(py_float_str(v), "1370020896463233.2");
1154 assert_eq!(py_float_str(20682518724725.062), "20682518724725.062");
1155 // And a plain (non-tie) rounding is unaffected.
1156 assert_eq!(py_float_str(0.30000000000000004), "0.30000000000000004");
1157 }
1158
1159 /// Quirk 17/18: `Decimal` keeps trailing zeros and has its own exponent
1160 /// thresholds, so it does not agree with `float` on either.
1161 #[test]
1162 fn decimal_str_is_not_float_repr() {
1163 // Trailing zeros are spoken.
1164 assert_eq!(ok(d("5.00")), "pemp point zero zero");
1165 // No "." in str(Decimal("5")) -> the *integer* branch.
1166 assert_eq!(ok(d("5")), "pemp");
1167 // Decimal goes positional four orders further down than float does.
1168 assert_eq!(ok(d("1e-5")), "zero point zero zero zero zero unan");
1169 assert!(f(1e-5).is_err());
1170 // ... and gives up one order earlier, with a capital, unpadded E.
1171 assert_eq!(
1172 value_err(d("1e-7")),
1173 "invalid literal for int() with base 10: '1E-7'"
1174 );
1175 assert_eq!(
1176 value_err(d("1E+21")),
1177 "invalid literal for int() with base 10: '1E+21'"
1178 );
1179 }
1180
1181 /// `py_decimal_str` is a port of `Decimal.__str__`, not `BigDecimal`'s
1182 /// `Display` — which normalises "0.0" to "0" and would drop a word.
1183 #[test]
1184 fn decimal_str_matches_python_not_bigdecimal_display() {
1185 assert_eq!(py_decimal_str(&BigDecimal::from_str("0.0").unwrap()), "0.0");
1186 assert_eq!(BigDecimal::from_str("0.0").unwrap().to_string(), "0");
1187 assert_eq!(ok(d("0.0")), "zero point zero");
1188 assert_eq!(py_decimal_str(&BigDecimal::from_str("1.10").unwrap()), "1.10");
1189 assert_eq!(py_decimal_str(&BigDecimal::from_str("1e-6").unwrap()), "0.000001");
1190 assert_eq!(py_decimal_str(&BigDecimal::from_str("-1.10").unwrap()), "-1.10");
1191 }
1192
1193 /// Quirk 19 / issue #603: the integer part goes through the 10^9 digit
1194 /// fallback (quirk 1), and the Decimal arm keeps the trillion-scale digits
1195 /// a float cast would have rounded away.
1196 #[test]
1197 fn large_values() {
1198 assert_eq!(ok(d("98746251323029.99")), "98746251323029 point nav nav");
1199 assert_eq!(ok(f(1234567890.5)), "1234567890 point pemp");
1200 // Just under the fallback: still spelled.
1201 assert_eq!(
1202 ok(f(123456789.5)),
1203 "unan kant ugent tri milion pevar kant hanter-kant c'hwec'h mil \
1204 seizh kant pevar-ugent nav point pemp"
1205 );
1206 }
1207}