num2words2_core/lang_gl.rs
1//! Port of `lang_GL.py` (Galician).
2//!
3//! Shape: **self-contained**. `Num2Word_GL` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `hasattr` probe in `Num2Word_Base.__init__` never fires: Python builds
6//! **no `self.cards`** and **never sets `self.MAXVAL`**. `to_cardinal` is
7//! overridden outright and drives `_int_to_word`, a plain recursive
8//! divide-by-scale routine. Consequently `cards`/`maxval`/`merge` stay at
9//! their trait defaults here, and there is **no overflow check at all** —
10//! `MAXVAL` does not exist on the instance, and touching it would be an
11//! `AttributeError`. Nothing in the four in-scope modes touches it.
12//!
13//! Inherited from `Num2Word_Base` and left alone by GL — but GL happens to
14//! re-declare two of them identically, so the observable behaviour is the
15//! same either way:
16//! * `to_ordinal_num(value) -> str(value) + "."` (GL overrides; the base
17//! default would have returned the value untouched, so this one *is* a
18//! real override.)
19//! * `to_year(val, longval=True) -> self.to_cardinal(val)` (GL overrides
20//! with a body identical to the base default; either way it delegates
21//! through `&self` and picks up the `to_cardinal` override below.)
22//!
23//! No cross-call mutable state: GL defines no `str_to_number` and stashes no
24//! pending flags, so the stateless Rust path is a faithful stand-in for the
25//! Python dispatcher's fast path.
26//!
27//! # Currency
28//!
29//! `Num2Word_GL` declares its own `CURRENCY_FORMS` **dict literal** — it does
30//! not subclass `Num2Word_EUR`, so it never sees the dict that
31//! `Num2Word_EN.__init__` mutates in place, and none of EN's ~24 extra codes
32//! (JPY/KWD/CHF/INR/...) leak in. Verified against the live interpreter: the
33//! table is exactly `{"EUR", "USD"}` and `id(Num2Word_GL.CURRENCY_FORMS) !=
34//! id(Num2Word_Base.CURRENCY_FORMS)`.
35//!
36//! `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both inherited from
37//! `Num2Word_Base` and both are `{}`, so `currency_adjective` and
38//! `currency_precision` stay at their trait defaults (`None` / `100`).
39//!
40//! GL **overrides `to_currency` outright** — `Num2Word_Base.to_currency` is
41//! never reached, so `pluralize`, `_cents_verbose`, `_cents_terse`,
42//! `parse_currency_parts` and the whole `CURRENCY_PRECISION` machinery are
43//! dead code for this language. `pluralize` therefore stays at the trait
44//! default that raises, exactly mirroring `Num2Word_Base.pluralize` — which
45//! also raises `NotImplementedError` and is likewise never called.
46//!
47//! `to_cheque` is **not** overridden, so the base implementation runs: it
48//! reaches `_money_verbose` (-> `to_cardinal`) and indexes
49//! `self.CURRENCY_FORMS[currency]` *directly*, which is why cheques raise
50//! where `to_currency` silently falls back. See bugs 12 and 13 below.
51//!
52//! # Faithfully reproduced Python bugs / oddities — currency
53//!
54//! 10. **An unknown currency code never raises.** `to_currency` looks the code
55//! up as `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS
56//! .values())[0])`, so `GBP`, `JPY`, `KWD`, `BHD`, `INR`, `CNY`, `CHF` —
57//! everything outside `{EUR, USD}` — silently render as **euros**, with
58//! `céntimo(s)` subunits. Corpus-confirmed: `currency:JPY` of `1` is
59//! `"un euro"`, not a `NotImplementedError`. See [`CURRENCY_FALLBACK_KEY`].
60//! 11. **`CURRENCY_PRECISION` is ignored by `to_currency`.** The subunit
61//! divisor is hard-wired to 100 by the `[:2].ljust(2, "0")` string slice,
62//! so the 3-decimal currencies get cents rather than mils and the
63//! 0-decimal ones are not rounded to whole units: `currency:KWD` of
64//! `1234.56` is `"... cincuenta seis céntimos"` (56/100), and
65//! `currency:JPY` of `12.34` still prints a cents segment. Moot in
66//! practice — bug 10 means neither code reaches its own forms anyway.
67//! 12. **`to_cheque` and `to_currency` disagree about unknown codes.** GL
68//! inherits `Num2Word_Base.to_cheque`, whose `self.CURRENCY_FORMS[
69//! currency]` is a bare subscript, so it raises `NotImplementedError` for
70//! the very codes `to_currency` happily aliases to euros. Corpus-confirmed:
71//! `cheque:GBP` -> `NotImplementedError`, `currency:GBP` -> `"un euro"`.
72//! 13. **Cents are truncated, never rounded.** `int(parts[1][:2].ljust(2,
73//! "0"))` slices the *decimal string*: `12.345` -> 34 céntimos (not 35),
74//! and `0.001` -> `"00"` -> 0 -> the cents segment vanishes entirely.
75//! 14. **`cents=False` drops the cents segment instead of digitising it.**
76//! `Num2Word_Base` routes `cents=False` to `_cents_terse` ("56"); GL's
77//! guard is `if cents and right:`, so `cents=False` emits no cents at all.
78//! 15. **`adjective=` is accepted and silently ignored.** GL takes the kwarg
79//! to keep the base signature but never reads it, and its inherited
80//! `CURRENCY_ADJECTIVES` is empty regardless.
81//! 16. **Zero cents suppress the segment even for floats.** `1.0` -> parts[1]
82//! is `"0"` -> `right == 0` -> falsy -> `"un euro"`. GL reaches the same
83//! place as the base's `isinstance(val, int)` shortcut, but by a different
84//! road: it never inspects the type, it just tests whether `right` is
85//! truthy. So for GL — uniquely — `1` and `1.0` genuinely agree, and the
86//! `Int`/`Decimal` split of [`CurrencyValue`] is behaviourally inert here.
87//! It is still honoured on both arms rather than collapsed.
88//! 17. **Scientific notation is a `ValueError`, not a number.** `to_currency`
89//! parses `str(val)`, so `1e16` -> `parts[0] == "1e+16"` ->
90//! `int("1e+16")` -> `ValueError`. See [`LangGl::split_currency`].
91//!
92//! # Faithfully reproduced Python bugs / oddities
93//!
94//! This is a port, not a rewrite. Every one of these looks wrong and is
95//! exactly what Python emits — all confirmed against the frozen corpus:
96//!
97//! 1. **No teens, no compound tens.** `_int_to_word` has no 11..19 table and
98//! joins tens to ones with a bare space, so 11 == "dez un" (literally
99//! "ten one", not "once") and 21 == "vinte un" (not "vinte e un").
100//! Galician normally uses "e" between tens and units; GL never emits it.
101//! 2. **"un cento", not "cen"/"cento".** The hundreds branch is
102//! unconditionally `ones[h] + " cento"`, so 100 == "un cento" (real
103//! Galician is "cen") and 200 == "dous cento" (real: "douscentos").
104//! 3. **"un mil", not "mil".** The thousands branch always recurses on the
105//! multiplier, so 1000 == "un mil" (real Galician: "mil").
106//! 4. **`millón` is never pluralised** and the scale word is never joined
107//! with "de": 10^7 == "dez millón" (real: "dez millóns").
108//! 5. **Hard ceiling at 10^9 that returns digits instead of words.** The
109//! final `else` of `_int_to_word` is `return str(number)` — a "fallback
110//! for very large numbers" that silently emits the numeral. So
111//! `to_cardinal(10**9)` == "1000000000" (a *string of digits*, not
112//! words), `to_ordinal(10**9)` == "1000000000-o", and this holds for
113//! every value >= 10^9 no matter how large. It does **not** raise
114//! `OverflowError`. Verified against corpus rows up to 10^21.
115//! Negatives inherit it too: `to_cardinal(-10**9)` == "minus 1000000000".
116//! 6. **`to_ordinal` is just the cardinal plus a literal `"-o"` suffix**,
117//! hyphen included: 0 == "zero-o", 100 == "un cento-o". Not a real
118//! Galician ordinal ("primeiro", "centésimo").
119//! 7. **`to_ordinal` accepts negatives** — it never calls
120//! `verify_ordinal()`, so `to_ordinal(-1)` == "minus un-o" rather than
121//! raising `TypeError` the way most modules would.
122//! 8. **`negword` is `"minus "`** — the English word, with a trailing space,
123//! in a Galician module (real: "menos"). GL's `to_cardinal` interpolates
124//! it *raw* rather than via the base's `"%s " % self.negword.strip()`
125//! idiom; the trailing space makes the two coincide, and the final
126//! `.strip()` cleans up the rest. See [`NEGWORD`].
127//! 9. **`ones[0]` is `""`**, so `_int_to_word(0)` takes the
128//! `self.ones[0] if self.ones[0] else "zero"` branch and yields the
129//! English-ish "zero" (which is, coincidentally, also correct Galician).
130//!
131//! # Errors
132//!
133//! None of the four in-scope modes can raise for integer input: every list
134//! index is derived from a `% 10` or `/ 100` of a bounded positive value, so
135//! `ones`/`tens` are never indexed out of range, and the 10^9 fallback
136//! swallows what would otherwise overflow. `Result` is returned only to
137//! satisfy the trait.
138
139use crate::base::{Lang, N2WError, Result};
140use crate::currency::{CurrencyForms, CurrencyValue};
141use crate::floatpath::FloatValue;
142use bigdecimal::BigDecimal;
143use num_bigint::BigInt;
144use num_traits::{Signed, ToPrimitive, Zero};
145use std::collections::HashMap;
146use std::str::FromStr;
147
148/// `self.negword`. Note the trailing space and the English word — both are
149/// verbatim from `lang_GL.py`'s `setup()`. GL's `to_cardinal` uses this raw
150/// (`ret = self.negword`), unlike `Num2Word_Base.to_cardinal`, which would
151/// have written `"%s " % self.negword.strip()`. The two agree here only
152/// because the literal already carries exactly one trailing space.
153const NEGWORD: &str = "minus ";
154
155/// `self.ones`. Index 0 is the empty string — that emptiness is load-bearing:
156/// it is what makes `_int_to_word(0)` fall through to the literal "zero".
157const ONES: [&str; 10] = [
158 "", "un", "dous", "tres", "catro", "cinco", "seis", "sete", "oito", "nove",
159];
160
161/// `self.tens`. Index 0 is unused (the `< 10` branch catches those first).
162/// There is deliberately no teens table — see module docs, bug 1.
163const TENS: [&str; 10] = [
164 "", "dez", "vinte", "trinta", "corenta", "cincuenta", "sesenta", "setenta", "oitenta",
165 "noventa",
166];
167
168const HUNDRED: &str = "cento";
169const THOUSAND: &str = "mil";
170const MILLION: &str = "millón";
171
172/// `self.__class__.__name__`, for `Num2Word_Base.to_cheque`'s
173/// `'Currency code "%s" not implemented for "%s"'`.
174const LANG_NAME: &str = "Num2Word_GL";
175
176/// The key of `list(self.CURRENCY_FORMS.values())[0]` — the entry
177/// `to_currency` falls back to for an unknown code (module docs, bug 10).
178///
179/// Python re-evaluates that `list(...)[0]` on *every* call and takes the first
180/// value in insertion order. `Num2Word_GL.CURRENCY_FORMS` is a dict literal
181/// with `"EUR"` written first and is never mutated, and dicts have preserved
182/// insertion order since 3.7, so the fallback is deterministically the EUR
183/// entry. A `HashMap` has no order, hence the explicit key.
184const CURRENCY_FALLBACK_KEY: &str = "EUR";
185
186/// `Num2Word_GL.to_currency`'s own default `separator=" "` — a bare space,
187/// where `Num2Word_Base` declares `separator=","`.
188///
189/// See [`SEPARATOR_UNSET`] for why this cannot be a plain parameter default.
190const SEPARATOR_DEFAULT: &str = " ";
191
192/// The separator the pyo3 binding passes when the Python caller omitted one.
193///
194/// `Num2Word_GL.to_currency` declares `separator=" "`, but the `Lang` trait has
195/// no per-language parameter defaults: `__init__.py`'s fast path and
196/// `bench/diff_test.py` both send `kwargs.get("separator", ",")` — i.e.
197/// `Num2Word_Base`'s default — so by the time the value reaches this side, the
198/// information needed to tell "unset" from "explicitly a comma" is gone.
199///
200/// So `,` is read back as the unset sentinel and GL's own default restored.
201/// This is the only reading that matches the oracle: all 54 float-with-cents
202/// rows of the `gl` currency corpus were generated by `num2words(v, lang="gl",
203/// to="currency", currency=c)` with no `separator=`, and every one of them
204/// expects a plain space ("... euros trinta catro céntimos"). Same convention
205/// and same sentinel as `lang_as.rs` and `lang_ca.rs`.
206///
207/// The cost is narrow and known: a caller who *explicitly* passes
208/// `separator=","` gets `" "` here where Python would give `","`. Fixing that
209/// properly needs `Option<&str>` in the trait signature, which lives in
210/// `base.rs` — outside this port's remit. Flagged in the port report.
211const SEPARATOR_UNSET: &str = ",";
212
213/// Holds the `CURRENCY_FORMS` table, built once in [`LangGl::new`].
214///
215/// The four integer modes are stateless — `setup()` only assigns constant
216/// tables, which live as `const`s above. The currency table is a `HashMap`
217/// rather than a `const` because `to_currency` needs keyed lookup; it is
218/// built once per process (`num2words2-py` holds a `OnceLock<LangGl>`) and
219/// never per call.
220pub struct LangGl {
221 /// `Num2Word_GL.CURRENCY_FORMS`. GL's own dict literal — see module docs.
222 currency_forms: HashMap<&'static str, CurrencyForms>,
223 /// `list(self.CURRENCY_FORMS.values())[0]`, cloned out at construction so
224 /// `to_currency`'s fallback is a field read rather than Python's per-call
225 /// list materialisation. See [`CURRENCY_FALLBACK_KEY`].
226 currency_fallback: CurrencyForms,
227}
228
229impl Default for LangGl {
230 fn default() -> Self {
231 Self::new()
232 }
233}
234
235impl LangGl {
236 pub fn new() -> Self {
237 // `Num2Word_GL.CURRENCY_FORMS`, verbatim. Two forms per side, matching
238 // Python's arity — `to_currency` indexes `cr1[1]`/`cr2[1]` directly.
239 let currency_forms: HashMap<&'static str, CurrencyForms> = [
240 (
241 "EUR",
242 CurrencyForms::new(&["euro", "euros"], &["céntimo", "céntimos"]),
243 ),
244 (
245 // Not a typo to fix: Python really spells the USD unit
246 // "dollar"/"dollars" (English) in a Galician module. Real
247 // Galician is "dólar"/"dólares". Corpus-confirmed.
248 "USD",
249 CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
250 ),
251 ]
252 .into_iter()
253 .collect();
254
255 let currency_fallback = currency_forms
256 .get(CURRENCY_FALLBACK_KEY)
257 .expect("CURRENCY_FALLBACK_KEY is inserted directly above")
258 .clone();
259
260 LangGl {
261 currency_forms,
262 currency_fallback,
263 }
264 }
265
266 /// Python's `parts = str(val).split(".")` and the `left`/`right`
267 /// extraction that follows it, for an already-absolute value.
268 ///
269 /// ```text
270 /// parts = str(val).split(".")
271 /// left = int(parts[0]) if parts[0] else 0
272 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
273 /// ```
274 ///
275 /// This is a **string** operation, not an arithmetic one, and that is
276 /// load-bearing twice over:
277 ///
278 /// * `[:2]` truncates rather than rounds (module docs, bug 13), and
279 /// * `.ljust(2, "0")` pads on the *right*, so a single fractional digit
280 /// scales up: `0.5` -> `"5"` -> `"50"` -> 50 céntimos, not 5.
281 ///
282 /// The `BigDecimal` was parsed from the `str(number)` the Python shim
283 /// sent, and `bigdecimal` preserves the written scale exactly (probed:
284 /// `"1.0"` -> `(10, 1)`, `"0.5"` -> `(5, 1)`, `"0.001"` -> `(1, 3)`), so
285 /// `scale` *is* `len(parts[1])` and the digit string can be rebuilt
286 /// losslessly. `abs()` preserves the scale too, and `str(abs(x))` differs
287 /// from `str(x)` only by the sign, so re-deriving the split from the
288 /// absolute value is exact.
289 ///
290 /// # Errors
291 ///
292 /// `scale < 0` means the incoming string carried an exponent with a
293 /// positive net power (`"1e+16"` -> `(1, -16)`; a plain `"10000000000000000"`
294 /// gives scale 0, so there is no false positive). Python's `str(val)` then
295 /// renders the same notation, and `int("1e+16")` raises **`ValueError`** —
296 /// reproduced here as [`N2WError::Value`]. The message is rebuilt from
297 /// `BigDecimal`'s own `Display`, which agrees with Python for float input
298 /// (`1e16` -> `"1e+16"`) but not for a `Decimal("1E+2")`, where Python
299 /// keeps `"1E+2"` and `Display` normalises to `"100"`. The exception
300 /// *type* is right either way; the corpus asserts only the type.
301 ///
302 /// # Known gap: small floats
303 ///
304 /// The mirror-image case is **not** detectable and is knowingly wrong.
305 /// Python's `repr` switches to exponent form below `1e-4` as well, so
306 /// `to_currency(1e-05)` also raises `ValueError` — but `"1e-05"` and
307 /// `"0.00001"` both parse to the *same* `BigDecimal` `(1, 5)`, and only the
308 /// first raises in Python (`str(Decimal("0.00001"))` keeps the plain form
309 /// and yields "zero euros"). The distinguishing information is the wire
310 /// string, which `CurrencyValue::Decimal` has already discarded by the time
311 /// this method runs. Telling them apart needs the raw `&str` carried
312 /// through `to_currency`, which lives in `currency.rs`/`base.rs` — outside
313 /// this port's remit.
314 ///
315 /// Blast radius: floats with `0 < |x| < 1e-4`, which Rust renders as
316 /// "zero euros" where Python raises `ValueError`. No corpus row covers it.
317 /// Flagged in the port report.
318 fn split_currency(&self, val: &BigDecimal) -> Result<(BigInt, BigInt)> {
319 let (int_val, scale) = val.as_bigint_and_exponent();
320
321 if scale < 0 {
322 // int("1e+16") -> ValueError. See the doc comment above.
323 return Err(N2WError::Value(format!(
324 "invalid literal for int() with base 10: '{}'",
325 val
326 )));
327 }
328
329 // scale == 0: `str(val)` has no ".", so `parts` has one element and
330 // `right` stays 0.
331 if scale == 0 {
332 return Ok((int_val, BigInt::zero()));
333 }
334
335 // `val` is absolute, so these digits carry no sign.
336 let scale = scale as usize;
337 let digits = int_val.to_string();
338 let padded = if digits.len() <= scale {
339 // Python always writes at least one integer digit ("0.5", never
340 // ".5"), so widen to scale+1 to keep parts[0] non-empty.
341 format!("{}{}", "0".repeat(scale + 1 - digits.len()), digits)
342 } else {
343 digits
344 };
345 let (int_part, frac_part) = padded.split_at(padded.len() - scale);
346
347 // Both halves are pure ASCII digits, so `chars()` and byte offsets
348 // coincide and neither parse can fail.
349 let left = BigInt::from_str(int_part).expect("int_part is ASCII digits");
350
351 // `parts[1][:2].ljust(2, "0")`
352 let mut frac: String = frac_part.chars().take(2).collect();
353 while frac.len() < 2 {
354 frac.push('0');
355 }
356 let right = BigInt::from_str(&frac).expect("frac is two ASCII digits");
357
358 Ok((left, right))
359 }
360
361 /// `_int_to_word`.
362 ///
363 /// Mirrors the Python cascade exactly, including the `str(number)`
364 /// fallback for `number >= 10**9` (module docs, bug 5).
365 ///
366 /// All divisions here run on non-negative values (the `< 0` arm recurses
367 /// on `abs` first), so Rust's truncating `/` and `%` agree with Python's
368 /// floor `//` and `%` — no `div_mod_floor` needed.
369 fn int_to_word(&self, number: &BigInt) -> String {
370 // Python: `return self.ones[0] if self.ones[0] else "zero"`.
371 // ONES[0] == "" is falsy, so this always yields "zero".
372 if number.is_zero() {
373 return if ONES[0].is_empty() {
374 "zero".to_string()
375 } else {
376 ONES[0].to_string()
377 };
378 }
379
380 // Unreachable from the four in-scope modes: `to_cardinal` strips the
381 // sign before calling in, so `_int_to_word` only ever sees
382 // non-negative values. Ported anyway because Python has it, and
383 // `to_currency` (out of scope) does reach `_int_to_word` directly.
384 if number.is_negative() {
385 return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
386 }
387
388 let ten = BigInt::from(10);
389 let hundred = BigInt::from(100);
390 let thousand = BigInt::from(1000);
391 let million = BigInt::from(1_000_000);
392 let billion = BigInt::from(1_000_000_000);
393
394 if number < &ten {
395 // 1..=9: always in range.
396 return ONES[idx(number)].to_string();
397 }
398
399 if number < &hundred {
400 let tens_val = number / &ten; // 1..=9
401 let ones_val = number % &ten; // 0..=9
402 if ones_val.is_zero() {
403 return TENS[idx(&tens_val)].to_string();
404 }
405 // Bare space join — no "e". See module docs, bug 1.
406 return format!("{} {}", TENS[idx(&tens_val)], ONES[idx(&ones_val)]);
407 }
408
409 if number < &thousand {
410 let hundreds_val = number / &hundred; // 1..=9
411 let remainder = number % &hundred;
412 // Unconditional "<ones> cento" — see module docs, bug 2.
413 let mut result = format!("{} {}", ONES[idx(&hundreds_val)], HUNDRED);
414 if !remainder.is_zero() {
415 result.push(' ');
416 result.push_str(&self.int_to_word(&remainder));
417 }
418 return result;
419 }
420
421 if number < &million {
422 let thousands_val = number / &thousand; // 1..=999
423 let remainder = number % &thousand;
424 // Always recurses on the multiplier — see module docs, bug 3.
425 let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
426 if !remainder.is_zero() {
427 result.push(' ');
428 result.push_str(&self.int_to_word(&remainder));
429 }
430 return result;
431 }
432
433 if number < &billion {
434 let millions_val = number / &million; // 1..=999
435 let remainder = number % &million;
436 // MILLION is never pluralised — see module docs, bug 4.
437 let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
438 if !remainder.is_zero() {
439 result.push(' ');
440 result.push_str(&self.int_to_word(&remainder));
441 }
442 return result;
443 }
444
445 // Python: `return str(number) # Fallback for very large numbers`.
446 // Emits digits, not words, and never raises. Module docs, bug 5.
447 number.to_string()
448 }
449
450 /// GL's `to_cardinal` string body, run on a reconstructed `str(number)`.
451 ///
452 /// ```text
453 /// n = str(number).strip()
454 /// if n.startswith("-"): n = n[1:]; ret = self.negword else: ret = ""
455 /// if "." in n:
456 /// left, right = n.split(".", 1)
457 /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
458 /// for digit in right:
459 /// ret += self._int_to_word(int(digit)) + " "
460 /// return ret.strip()
461 /// else:
462 /// return (ret + self._int_to_word(int(n))).strip()
463 /// ```
464 ///
465 /// The reconstructed `s` never carries surrounding whitespace, but the
466 /// `.strip()`/`.trim()` bookends are reproduced anyway. `int(left)` and
467 /// `int(digit)` on any non-digit token raise `ValueError` — unreachable
468 /// once the fixed-notation callers have filtered exponent forms out, but
469 /// guarded regardless so the failure mode stays GL's.
470 fn cardinal_from_py_str(&self, s: &str) -> Result<String> {
471 let n = s.trim();
472 let (neg, n) = match n.strip_prefix('-') {
473 Some(rest) => (true, rest),
474 None => (false, n),
475 };
476 let mut ret = String::new();
477 if neg {
478 // `ret = self.negword` — raw "minus " (trailing space is the join).
479 ret.push_str(NEGWORD);
480 }
481 if let Some((left, right)) = n.split_once('.') {
482 // int(left): the integer part (may exceed 10^9 -> str fallback).
483 let left_int = BigInt::from_str(left).map_err(|_| int_value_err(left))?;
484 ret.push_str(&self.int_to_word(&left_int));
485 ret.push(' ');
486 ret.push_str(self.pointword());
487 ret.push(' ');
488 // Digitise each fractional character via int(digit).
489 for ch in right.chars() {
490 let d = ch
491 .to_digit(10)
492 .ok_or_else(|| int_value_err(&ch.to_string()))?;
493 ret.push_str(&self.int_to_word(&BigInt::from(d)));
494 ret.push(' ');
495 }
496 Ok(ret.trim().to_string())
497 } else {
498 let v = BigInt::from_str(n).map_err(|_| int_value_err(n))?;
499 ret.push_str(&self.int_to_word(&v));
500 Ok(ret.trim().to_string())
501 }
502 }
503}
504
505/// Narrow a bounded `BigInt` (0..=9 at every call site) to a list index.
506///
507/// Every caller has already proven the value is a single digit via a
508/// `% 10` or a `< 10` / `/ 100` bound, so `to_usize` cannot fail; the
509/// `unwrap_or(0)` is belt-and-braces and unreachable.
510fn idx(n: &BigInt) -> usize {
511 n.to_usize().unwrap_or(0)
512}
513
514/// `int(token)`'s `ValueError` message. GL never catches it, so only the
515/// exception *type* is observable; the text mirrors CPython for readability.
516fn int_value_err(token: &str) -> N2WError {
517 N2WError::Value(format!(
518 "invalid literal for int() with base 10: '{}'",
519 token
520 ))
521}
522
523/// Rust's `{:e}` yields the shortest round-trip mantissa+exponent — the same
524/// significant-digit sequence CPython's `repr` uses — so `(digits, e)` here
525/// satisfies `fabs == D.DDD * 10^e` with `digits` carrying no leading or
526/// trailing zeros (except the lone `"0"` for `fabs == 0.0`).
527///
528/// This is the load-bearing choice for GL: the language digitises the *repr
529/// string* rather than `base.float2tuple`'s `abs(value-pre)*10^precision`, and
530/// the two disagree in the last fractional digit once a float carries ~16
531/// significant digits (the multiply-out drifts; the repr does not). Reading
532/// the digits straight off `{:e}` reproduces `str(float)` exactly.
533fn float_e_parts(fabs: f64) -> (String, i64) {
534 if fabs == 0.0 {
535 return ("0".to_string(), 0);
536 }
537 let s = format!("{:e}", fabs);
538 // `{:e}` is always `<mant>e<exp>` with `<mant>` a normalised `d[.ddd]`.
539 let (mant, exp) = s.split_once('e').expect("{:e} always contains 'e'");
540 let e0: i64 = exp.parse().expect("{:e} exponent is an integer");
541 let (ip, fp) = match mant.split_once('.') {
542 Some((i, f)) => (i, f),
543 None => (mant, ""),
544 };
545 let mut digits = String::with_capacity(ip.len() + fp.len());
546 digits.push_str(ip);
547 digits.push_str(fp);
548 let trimmed = digits.trim_end_matches('0');
549 let digits = if trimmed.is_empty() {
550 "0".to_string()
551 } else {
552 trimmed.to_string()
553 };
554 // `ip` is a single significant digit, so the leading-digit exponent is
555 // `e0 + len(ip) - 1`.
556 (digits, e0 + ip.len() as i64 - 1)
557}
558
559/// Reconstruct CPython's `str(f)` for a finite float in the fixed-notation
560/// range, or `Err(Value)` where GL would raise `ValueError`.
561///
562/// `repr(float)` switches to exponent form when the decimal point position
563/// `decpt` (with `value == 0.d1d2… * 10^decpt`) satisfies `decpt <= -4` or
564/// `decpt > 16`. GL then feeds a token containing `'e'` (or `"nan"`/`"inf"`)
565/// to `int()`, which raises `ValueError`. Everything else is fixed-point,
566/// rebuilt exactly as CPython's `format_float_short` does (always keeping a
567/// decimal point, e.g. `1.0`, since GL relies on the `"."` to take its
568/// fractional branch).
569fn float_to_py_str(f: f64) -> Result<String> {
570 if !f.is_finite() {
571 // str(nan)/str(inf)/str(-inf): no '.', so int(token) -> ValueError.
572 return Err(int_value_err(&format!("{}", f)));
573 }
574 // str(-0.0) == "-0.0": the sign is the sign *bit*, not `f < 0`.
575 let neg = f.is_sign_negative();
576 let fabs = f.abs();
577 let (digits, e) = float_e_parts(fabs);
578 let decpt = e + 1;
579 if fabs != 0.0 && (decpt <= -4 || decpt > 16) {
580 // repr uses exponent notation here -> GL's int() -> ValueError.
581 return Err(int_value_err(&format!("{:e}", fabs)));
582 }
583 let dlen = digits.len() as i64;
584 let body = if decpt <= 0 {
585 // 0.00…digits
586 format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
587 } else if decpt >= dlen {
588 // digits with trailing zeros, then the mandatory ".0"
589 format!("{}{}.0", digits, "0".repeat((decpt - dlen) as usize))
590 } else {
591 // split the digits at the point (digits are ASCII, so byte = char)
592 let k = decpt as usize;
593 format!("{}.{}", &digits[..k], &digits[k..])
594 };
595 Ok(if neg { format!("-{}", body) } else { body })
596}
597
598/// Reconstruct CPython's `str(Decimal)` from a `BigDecimal`, or `Err(Value)`
599/// where GL would raise `ValueError`.
600///
601/// `Decimal.__str__` uses exponent form when `exp > 0` or the adjusted
602/// exponent `exp + len(coeff) - 1 < -6`; GL then hands a `'E'`-bearing token
603/// to `int()` and raises `ValueError`. The fixed-point branch reproduces
604/// Decimal's own algorithm, and — because `bigdecimal` preserves the written
605/// scale (`"1.10" -> (110, 2)`) — the coefficient and exponent match CPython's
606/// `as_tuple()` exactly, trailing zeros included.
607fn decimal_to_py_str(d: &BigDecimal) -> Result<String> {
608 // value == mantissa * 10^-scale, i.e. Decimal exponent `exp == -scale`.
609 let (mantissa, scale) = d.as_bigint_and_exponent();
610 let neg = mantissa.is_negative();
611 let coeff = mantissa.abs().to_string(); // significant digits; "0" for zero
612 let exp: i64 = -scale;
613 let adjexp = exp + coeff.len() as i64 - 1;
614 if exp > 0 || adjexp < -6 {
615 // Decimal renders exponent notation here -> GL's int() -> ValueError.
616 return Err(int_value_err(&format!("{}", d)));
617 }
618 let sign = if neg { "-" } else { "" };
619 if exp == 0 {
620 // Integer-valued Decimal: str() has no '.', GL takes its whole branch.
621 return Ok(format!("{}{}", sign, coeff));
622 }
623 // exp < 0, i.e. scale > 0: fixed-point with `scale` fractional digits.
624 let scale_u = scale as usize;
625 let padded = if coeff.len() <= scale_u {
626 // Keep at least one integer digit ("0.001", never ".001").
627 format!("{}{}", "0".repeat(scale_u + 1 - coeff.len()), coeff)
628 } else {
629 coeff
630 };
631 let (ip, fp) = padded.split_at(padded.len() - scale_u);
632 Ok(format!("{}{}.{}", sign, ip, fp))
633}
634
635impl Lang for LangGl {
636
637 fn cardinal_float_entry(
638 &self,
639 value: &crate::floatpath::FloatValue,
640 precision_override: Option<u32>,
641 ) -> crate::base::Result<String> {
642 // Python's to_cardinal routes every float/Decimal through this
643 // language's own decimal grammar — 5.0 keeps its ".0" tail
644 // ("comma nulla"), unlike Base's whole-value integer route.
645 self.to_cardinal_float(value, precision_override)
646 }
647
648 /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
649 /// `to_cardinal(number) + "-o"` for *any* input (no
650 /// `verify_ordinal`), so the float path is the float cardinal put through
651 /// the same literal transformation: `5.0` -> "cinco point zero-o".
652 /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
653 /// before the transformation, exactly as in Python.
654 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
655 let cardinal = self.cardinal_float_entry(value, None)?;
656 Ok(format!("{}-o", cardinal))
657 }
658
659 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. `repr_str` is the
660 /// dispatcher's exact `str(value)` (float repr / `Decimal.__str__`), so
661 /// trailing zeros and `1E+2`-style exponent forms survive verbatim.
662 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
663 Ok(format!("{}.", repr_str))
664 }
665
666 /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
667 /// Infinity sentinel becomes the ValueError this language's own
668 /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
669 /// fails); the shared dispatcher would otherwise report Base's
670 /// OverflowError. NaN keeps the base sentinel: the dispatcher's
671 /// ValueError for it already matches `int("NaN")`.
672 fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
673 match crate::strnum::python_decimal_parse(s)? {
674 crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
675 "invalid literal for int() with base 10: 'Infinity'".into(),
676 )),
677 p => Ok(p),
678 }
679 }
680
681 /// This language's own `to_currency(currency=...)` default,
682 /// read from the live Python signature. Only 44 of 156 use EUR.
683 fn default_currency(&self) -> &str {
684 "EUR"
685 }
686
687 /// This language's own `to_currency(separator=...)` default,
688 /// read from the live Python signature. Base's is ",", but only
689 /// 36 of 149 languages actually use it — most default to " " or a
690 /// conjunction, so inheriting Base's comma silently corrupts them.
691 fn default_separator(&self) -> &str {
692 " "
693 }
694
695 fn negword(&self) -> &str {
696 // Verbatim, trailing space included — see NEGWORD.
697 NEGWORD
698 }
699
700 fn pointword(&self) -> &str {
701 "point"
702 }
703
704 /// `to_cardinal`.
705 ///
706 /// Python works on `str(number)`: it detaches a leading "-", sets
707 /// `ret = self.negword`, and (for integers — no "." in the digits)
708 /// returns `(ret + self._int_to_word(int(n))).strip()`.
709 ///
710 /// Splitting on `is_negative()`/`abs()` is equivalent for integer input:
711 /// `str(BigInt)` yields exactly one optional leading "-" followed by
712 /// digits, and Python ints have no "-0".
713 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
714 let (ret, n) = if value.is_negative() {
715 (NEGWORD, value.abs())
716 } else {
717 ("", value.clone())
718 };
719 // Python's trailing `.strip()`. `_int_to_word` never returns padding,
720 // so in practice this only trims NEGWORD's space off a "-0"-ish edge
721 // that BigInt cannot produce — but reproduce it regardless.
722 Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
723 }
724
725 /// `to_ordinal`: cardinal + a literal "-o". No `verify_ordinal()` call,
726 /// so negatives sail through. Module docs, bugs 6 and 7.
727 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
728 let cardinal = self.to_cardinal(value)?;
729 Ok(format!("{}-o", cardinal))
730 }
731
732 /// `to_ordinal_num`: `str(number) + "."`. Digits, never words, and the
733 /// minus sign survives: -1 -> "-1.".
734 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
735 Ok(format!("{}.", value))
736 }
737
738 /// `to_year(val, longval=True)`: ignores `longval` entirely and just
739 /// delegates to `to_cardinal`. No BC/AD suffix, no era handling — so
740 /// -500 -> "minus cinco cento".
741 fn to_year(&self, value: &BigInt) -> Result<String> {
742 self.to_cardinal(value)
743 }
744
745 /// The float / Decimal cardinal path.
746 ///
747 /// **Routing note.** In Python GL overrides `to_cardinal`, *not*
748 /// `to_cardinal_float`: `num2words(0.5, lang="gl")` dispatches to
749 /// `to_cardinal`, which handles non-integers inline off `str(number)`
750 /// (`if "." in n: ...`). The inherited `Num2Word_Base.to_cardinal_float`
751 /// is dead code for GL. The Rust core routes float input here instead, so
752 /// this override reproduces GL's *inline* handling rather than the base
753 /// path — which is why GL cannot inherit `default_to_cardinal_float`:
754 ///
755 /// * GL digitises the raw `repr` string, so a float with ~16 significant
756 /// digits keeps its exact `str()` digits, whereas `float2tuple`'s
757 /// `abs(value-pre)*10^precision` drifts in the last one
758 /// (`0.44308006468156513` -> "…un tres" vs the base's "…un dous").
759 /// * A value whose `repr`/`str` uses exponent notation
760 /// (`|x| >= 1e16`, `0 < |x| < 1e-4`, or an equivalently small Decimal)
761 /// is fed to `int()` and raises `ValueError` — where the base path
762 /// would happily digitise it.
763 ///
764 /// **`precision_override` is ignored**, matching Python: `precision=` is
765 /// popped in `__init__.py` and, although GL *has* a `self.precision`
766 /// attribute, its `to_cardinal` never reads it (it digitises the full
767 /// `str(number)`), so the kwarg is inert for GL.
768 fn to_cardinal_float(
769 &self,
770 value: &FloatValue,
771 _precision_override: Option<u32>,
772 ) -> Result<String> {
773 // Rebuild `str(number)` exactly, then run GL's string body on it.
774 let s = match value {
775 FloatValue::Float { value, .. } => float_to_py_str(*value)?,
776 FloatValue::Decimal { value, .. } => decimal_to_py_str(value)?,
777 };
778 self.cardinal_from_py_str(&s)
779 }
780
781 // ---- currency ------------------------------------------------------
782
783 fn lang_name(&self) -> &str {
784 LANG_NAME
785 }
786
787 /// `self.CURRENCY_FORMS[code]` — the **strict** lookup, matching the bare
788 /// subscript in `Num2Word_Base.to_cheque`. `None` here becomes that
789 /// method's `NotImplementedError`.
790 ///
791 /// Deliberately *not* where bug 10's euro fallback lives: `to_cheque` must
792 /// keep raising for the codes `to_currency` aliases, so the fallback stays
793 /// local to [`LangGl::to_currency`], exactly as `.get(k, default)` vs
794 /// `[k]` splits them in Python.
795 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
796 self.currency_forms.get(code)
797 }
798
799 /// `Num2Word_GL.to_currency` — a full override; `Num2Word_Base
800 /// .to_currency` is never reached and neither is `pluralize`.
801 ///
802 /// ```text
803 /// is_negative = False
804 /// if val < 0:
805 /// is_negative = True
806 /// val = abs(val)
807 /// parts = str(val).split(".")
808 /// left = int(parts[0]) if parts[0] else 0
809 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
810 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
811 /// left_str = self._int_to_word(left)
812 /// result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
813 /// if cents and right:
814 /// cents_str = self._int_to_word(right)
815 /// result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
816 /// if is_negative:
817 /// result = self.negword + result
818 /// return result.strip()
819 /// ```
820 ///
821 /// Note it drives `_int_to_word`, not `to_cardinal` — equivalent here,
822 /// since `val` has been made absolute and `to_cardinal` on a non-negative
823 /// integer is just `_int_to_word` plus a no-op `.strip()`.
824 fn to_currency(
825 &self,
826 val: &CurrencyValue,
827 currency: &str,
828 cents: bool,
829 separator: Option<&str>,
830 adjective: bool,
831 ) -> Result<String> {
832 // Trait now hands us None when the caller omitted separator=;
833 // resolve it to this language's own default before the ported body.
834 let separator = separator.unwrap_or(self.default_separator());
835 // Restore GL's own `separator=" "` default; see SEPARATOR_UNSET.
836 let separator = if separator == SEPARATOR_UNSET {
837 SEPARATOR_DEFAULT
838 } else {
839 separator
840 };
841 // `adjective` is in the signature and never read (module docs, bug 15).
842 let _ = adjective;
843
844 // `if val < 0: is_negative = True; val = abs(val)`, then the
845 // `str(val).split(".")` extraction.
846 //
847 // The Int/Decimal arms are kept apart even though GL — alone among the
848 // ported languages — cannot tell them apart (module docs, bug 16): an
849 // int stringifies without a ".", so `parts` has one element and `right`
850 // is 0, which is the same place the base's `isinstance(val, int)`
851 // shortcut lands. Reached by a different route, so ported by that route.
852 let (left, right, is_negative) = match val {
853 CurrencyValue::Int(v) => {
854 let is_negative = v < &BigInt::zero();
855 (v.abs(), BigInt::zero(), is_negative)
856 }
857 CurrencyValue::Decimal { value: d, .. } => {
858 // `val < 0` — a comparison, not `is_negative()`: Python reads
859 // `-0.0 < 0` as False, and so does this.
860 let is_negative = d < &BigDecimal::zero();
861 let (left, right) = self.split_currency(&d.abs())?;
862 (left, right, is_negative)
863 }
864 };
865
866 // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — an unknown
867 // code silently becomes euros (module docs, bug 10).
868 let forms = self
869 .currency_forms
870 .get(currency)
871 .unwrap_or(&self.currency_fallback);
872 // Both sides always carry exactly two forms (the table is the const
873 // literal built in `new`), so Python's unguarded `cr1[1]`/`cr2[1]` —
874 // and these index expressions — cannot go out of range.
875 let cr1 = &forms.unit;
876 let cr2 = &forms.subunit;
877
878 let one = BigInt::from(1);
879 let left_str = self.int_to_word(&left);
880 let mut result = format!(
881 "{} {}",
882 left_str,
883 if left != one { &cr1[1] } else { &cr1[0] }
884 );
885
886 // `if cents and right:` — a truthiness test on `right`, so zero cents
887 // drop the whole segment (bug 16), and `cents=False` drops it too
888 // rather than falling back to `_cents_terse` (bug 14).
889 if cents && !right.is_zero() {
890 let cents_str = self.int_to_word(&right);
891 // Python: `result += separator + cents_str + " " + ...`. No space
892 // is inserted *before* the separator; GL's " " default supplies
893 // the single space itself.
894 result.push_str(separator);
895 result.push_str(¢s_str);
896 result.push(' ');
897 result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
898 }
899
900 if is_negative {
901 // `self.negword` raw — the trailing space in "minus " is what
902 // separates it from the number. See NEGWORD.
903 result = format!("{}{}", NEGWORD, result);
904 }
905
906 // Python's trailing `.strip()`. Nothing here can produce padding, but
907 // reproduce it regardless.
908 Ok(result.trim().to_string())
909 }
910}