Skip to main content

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(&cents_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}