num2words2_core/lang_eo.rs
1//! Port of `lang_EO.py` (Esperanto).
2//!
3//! Shape: **engine**. `Num2Word_EO` subclasses `Num2Word_Base` directly and
4//! supplies `high_numwords`/`mid_numwords`/`low_numwords` + `merge`, letting
5//! the base `to_cardinal` drive `splitnum`/`clean`. So `cards`/`maxval`/`merge`
6//! are all live here and the heavy lifting happens in `base.rs`.
7//!
8//! Inherited from `Num2Word_Base` unchanged (trait defaults do the right
9//! thing):
10//! * `to_year(value) -> self.to_cardinal(value)` — EO does **not** override
11//! `to_year`, so years are plain cardinals: 1100 -> "milcent",
12//! 2100 -> "du milcent", -500 -> "minus kvincent". No "BC"/"AD" suffix,
13//! no century splitting. Verified against the corpus's 36 year rows.
14//! * `title()` — `is_title` is False, so it is the identity. `exclude_title`
15//! is populated for fidelity with `setup()` but is never consulted.
16//!
17//! # Scale
18//!
19//! EO is **long scale**, built from `GIGA_SUFFIX = "iliardo"` /
20//! `MEGA_SUFFIX = "iliono"`: 10^6 "miliono", 10^9 "miliardo", 10^12 "biliono",
21//! 10^15 "biliardo", 10^18 "triliono", 10^21 "triliardo", ... up to
22//! 10^600 "centiliono" / 10^603 "centiliardo". `MAXVAL` is
23//! `1000 * 10^603 == 10^606`, hence `BigInt` throughout — this language is a
24//! concrete reason `u64`/`i128` are not sufficient.
25//!
26//! # Faithfully reproduced Python bugs
27//!
28//! This is a port, not a rewrite. Both of the following look wrong and are
29//! exactly what Python emits:
30//!
31//! 1. **`merge` adds where it should multiply.** Every arm returns
32//! `cnum + nnum`, including the `nnum >= 10**6` arm that in every sibling
33//! language (EN/EUR) returns `cnum * nnum`. So the running `num` is
34//! nonsense — `merge(("du",2), ("miliono",10^6))` yields num `1000002`,
35//! not `2000000`, and `merge(("du",2), ("cent",100))` yields `102`, not
36//! `200`. The *text* is right, but the corrupted num feeds the next
37//! `merge`'s `cnum == 1` / `cnum > 1` / `nnum >= 10**6` tests, which is
38//! how bug 2 below detonates. Do not "fix" this to `*`: it would change
39//! output.
40//!
41//! 2. **Spurious plural "j" mid-number**, a direct consequence of 1. The arm
42//! `nnum >= 10**6 and cnum > 1 -> "%s %sj"` is meant to pluralize an
43//! illion ("du milionoj"). But because bug 1 lets `nnum` accumulate past
44//! 10^6 for a *non*-illion right operand, the "j" gets stapled onto
45//! whatever word happens to end the right fragment. Corpus proof:
46//!
47//! ```text
48//! to_cardinal(1234567890)
49//! == "unu miliardo ducent tridek kvar milionoj kvincent sesdek sep mil okcent naŭdekj"
50//! ```
51//!
52//! The final merge is `("unu miliardo", 1000000001)` + `("ducent ... naŭdek",
53//! 1001506)`; the right num crossed 10^6 purely by accumulation, so the arm
54//! fires and appends "j" to "naŭdek" (90). The corpus also records
55//! `98746251323029.99` -> "... dudek naŭjj komo naŭ naŭ" — a *double* "j"
56//! from the same mechanism firing twice. (That row is a float and out of
57//! scope here, but it confirms the mechanism.)
58//!
59//! 3. `gen_high_numwords` is overridden to **drop** the Latin-elision
60//! replacement table that `lang_EUR`/`lang_EN` apply. EO therefore ships the
61//! raw concatenations: "unnonagint", "seksnonagint", "novemdek",
62//! "tredek", ... rather than the elided "unnonagint"->"unonagint" style
63//! forms. Kept verbatim — these only surface above 10^60 anyway.
64//!
65//! # Ordinals
66//!
67//! `to_ordinal` scans `self.ords` **in Python dict insertion order** and
68//! returns on the first `endswith` hit. The order is load-bearing: "du"
69//! precedes "dek", so "dek du" -> "dek dua" (matching "du"), while "dudek"
70//! -> "dudeka" (falls through to "dek"). `ORDS` below is a `Vec`, not a
71//! `HashMap`, precisely to preserve that order.
72//!
73//! Falling off the table appends "a", with an "o"/"oj" strip first — so
74//! "unu miliono" -> "unu miliona", "dek milionoj" -> "dek miliona", and the
75//! bug-2 output "...naŭdekj" -> "...naŭdekja" (ends in "j", matches neither
76//! "o" nor "oj", so it just gains an "a").
77
78use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
79use crate::currency::CurrencyForms;
80use num_bigint::BigInt;
81use num_traits::{One, Signed, Zero};
82use std::collections::HashMap;
83
84/// `Num2Word_EO.CURRENCY_FORMS`, verbatim.
85///
86/// EO subclasses `Num2Word_Base` **directly**, not `Num2Word_EUR`, and declares
87/// its own `CURRENCY_FORMS` class attribute. So the famous mutation trap does
88/// not apply here: `Num2Word_EN.__init__` rewrites `Num2Word_EUR`'s shared class
89/// dict in place, but EO never reads that dict — confirmed against the live
90/// interpreter, which shows exactly these five codes and
91/// `CURRENCY_FORMS is not Num2Word_Base.CURRENCY_FORMS`.
92///
93/// The corollary is the corpus's long tail of NotImplementedError: EO gets none
94/// of the ~24 codes EN grafts on, so JPY/KWD/BHD/INR/CHF all raise. That is the
95/// expected behaviour, not a gap in this table — do not "helpfully" add them.
96fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
97 const CENTIMO: [&str; 2] = ["centimo", "centimoj"];
98
99 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
100 m.insert("EUR", CurrencyForms::new(&["eŭro", "eŭroj"], &CENTIMO));
101 m.insert("USD", CurrencyForms::new(&["dolaro", "dolaroj"], &["cendo", "cendoj"]));
102 m.insert("FRF", CurrencyForms::new(&["franko", "frankoj"], &CENTIMO));
103 m.insert("GBP", CurrencyForms::new(&["pundo", "pundoj"], &["penco", "pencoj"]));
104 m.insert("CNY", CurrencyForms::new(&["juano", "juanoj"], &["feno", "fenoj"]));
105 m
106}
107
108/// Port of `Num2Word_EO.gen_high_numwords`.
109///
110/// Unlike `lang_EUR`'s version, EO applies **no** elision replacements — it is
111/// a bare cross product, reversed, with `lows` appended. See module bug 3.
112fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
113 // Python: [u + t for t in tens for u in units] — `tens` is the OUTER loop.
114 let mut out: Vec<String> = Vec::new();
115 for t in tens {
116 for u in units {
117 out.push(format!("{}{}", u, t));
118 }
119 }
120 out.reverse();
121 out.extend(lows.iter().map(|s| s.to_string()));
122 out
123}
124
125/// `self.ords`, in Python dict **insertion order**. The order decides which
126/// `endswith` wins; see the module docs.
127const ORDS: &[(&str, &str)] = &[
128 ("unu", "unua"),
129 ("du", "dua"),
130 ("tri", "tria"),
131 ("kvar", "kvara"),
132 ("kvin", "kvina"),
133 ("ses", "sesa"),
134 ("sep", "sepa"),
135 ("ok", "oka"),
136 ("naŭ", "naŭa"),
137 ("dek", "deka"),
138];
139
140pub struct LangEo {
141 cards: Cards,
142 maxval: BigInt,
143 exclude_title: Vec<String>,
144 currency_forms: HashMap<&'static str, CurrencyForms>,
145}
146
147impl Default for LangEo {
148 fn default() -> Self {
149 Self::new()
150 }
151}
152
153impl LangEo {
154 pub fn new() -> Self {
155 // setup()
156 let lows = ["naŭ", "ok", "sep", "ses", "kvin", "kvar", "tr", "b", "m"];
157 let units = [
158 "", "un", "duo", "tre", "kvatuor", "kvin", "seks", "septen", "okto", "novem",
159 ];
160 let tens = [
161 "dek",
162 "vigint",
163 "trigint",
164 "kvadragint",
165 "kvinkvagint",
166 "seksagint",
167 "septuagint",
168 "oktogint",
169 "nonagint",
170 ];
171
172 // high_numwords = ["cent"] + gen_high_numwords(...) -> 1 + 90 + 9 = 100
173 let mut high = vec!["cent".to_string()];
174 high.extend(gen_high_numwords(&units, &tens, &lows));
175
176 let mut cards = Cards::new();
177
178 // set_high_numwords: cap = 3 + 6*len(high); zip(high, range(cap, 3, -6)).
179 // Both sides are length 100, but `zip` stops at the shorter, so the
180 // `n <= 3` break reproduces the range exhausting first if `high` ever
181 // grew. GIGA/MEGA suffixes are both non-empty, so both arms always run.
182 const GIGA_SUFFIX: &str = "iliardo";
183 const MEGA_SUFFIX: &str = "iliono";
184 let cap = 3 + 6 * high.len() as u32;
185 let ten = BigInt::from(10u8);
186 let mut n = cap;
187 for word in high.iter() {
188 if n <= 3 {
189 break;
190 }
191 cards.insert(ten.pow(n), format!("{}{}", word, GIGA_SUFFIX));
192 cards.insert(ten.pow(n - 3), format!("{}{}", word, MEGA_SUFFIX));
193 n -= 6;
194 }
195
196 set_mid_numwords(
197 &mut cards,
198 &[
199 (1000, "mil"),
200 (100, "cent"),
201 (90, "naŭdek"),
202 (80, "okdek"),
203 (70, "sepdek"),
204 (60, "sesdek"),
205 (50, "kvindek"),
206 (40, "kvardek"),
207 (30, "tridek"),
208 ],
209 );
210 set_low_numwords(
211 &mut cards,
212 &[
213 "dudek", "dek naŭ", "dek ok", "dek sep", "dek ses", "dek kvin", "dek kvar",
214 "dek tri", "dek du", "dek unu", "dek", "naŭ", "ok", "sep", "ses", "kvin", "kvar",
215 "tri", "du", "unu", "nul",
216 ],
217 );
218
219 // MAXVAL = 1000 * list(self.cards.keys())[0] — the first-inserted key is
220 // the largest (10^603), and Cards is sorted descending, so `highest()`
221 // is the same element. MAXVAL == 10^606.
222 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
223
224 LangEo {
225 cards,
226 maxval,
227 exclude_title: vec!["kaj".into(), "komo".into(), "minus".into()],
228 // Built once here, never per call: `to_currency` only reads it, and
229 // rebuilding the table on every call is what made an earlier
230 // revision of this port slower than the Python it replaces.
231 currency_forms: build_currency_forms(),
232 }
233 }
234
235 /// `Num2Word_Base.verify_ordinal`. Integral input clears the first check
236 /// (`value == int(value)`) unconditionally; only the negative check can fire.
237 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
238 if value.is_negative() {
239 return Err(N2WError::Type(format!(
240 "Cannot treat negative num {} as ordinal.",
241 value
242 )));
243 }
244 Ok(())
245 }
246
247 /// `Num2Word_Base.verify_ordinal` over the float/Decimal entry, plus the
248 /// truncating `int(value)` the two checks perform first. Rules, in
249 /// Python's order:
250 ///
251 /// * `int(nan)` raises ValueError and `int(±inf)` OverflowError *inside*
252 /// the first comparison, before either TypeError arm can fire;
253 /// * a fractional value (`value != int(value)`) is `errmsg_floatord` —
254 /// EO inherits base's English message;
255 /// * a negative whole value (`abs(value) != value`) is `errmsg_negord`.
256 /// `-0.0` passes both checks (`abs(-0.0) == -0.0`) and ordinalises as
257 /// zero → "nula".
258 ///
259 /// Returns the whole value as the integer the ordinal path then renders.
260 fn verify_ordinal_float(&self, value: &crate::floatpath::FloatValue) -> Result<BigInt> {
261 if let crate::floatpath::FloatValue::Float { value: f, .. } = value {
262 if f.is_nan() {
263 return Err(N2WError::Value(
264 "cannot convert float NaN to integer".into(),
265 ));
266 }
267 if f.is_infinite() {
268 return Err(N2WError::Overflow(
269 "cannot convert float infinity to integer".into(),
270 ));
271 }
272 }
273 match value.as_whole_int() {
274 None => Err(N2WError::Type(
275 "Cannot treat float as ordinal.".into(),
276 )),
277 Some(i) if i.is_negative() => Err(N2WError::Type(format!(
278 "Cannot treat negative num {} as ordinal.",
279 i
280 ))),
281 Some(i) => Ok(i),
282 }
283 }
284}
285
286impl Lang for LangEo {
287 /// This language's own `to_currency(currency=...)` default,
288 /// read from the live Python signature. Only 44 of 156 use EUR.
289 fn default_currency(&self) -> &str {
290 "EUR"
291 }
292
293 /// This language's own `to_currency(separator=...)` default,
294 /// read from the live Python signature. Base's is ",", but only
295 /// 36 of 149 languages actually use it — most default to " " or a
296 /// conjunction, so inheriting Base's comma silently corrupts them.
297 fn default_separator(&self) -> &str {
298 " kaj"
299 }
300
301 fn cards(&self) -> &Cards {
302 &self.cards
303 }
304 fn maxval(&self) -> &BigInt {
305 &self.maxval
306 }
307 fn negword(&self) -> &str {
308 "minus "
309 }
310 fn pointword(&self) -> &str {
311 "komo"
312 }
313 fn exclude_title(&self) -> &[String] {
314 &self.exclude_title
315 }
316
317 /// Port of `Num2Word_EO.merge`. Note every arm returns `cnum + nnum` —
318 /// never a product. That is the Python source's behaviour and it is
319 /// load-bearing; see module bugs 1 and 2.
320 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
321 let (ctext, cnum) = l;
322 let (ntext, nnum) = r;
323 let million = BigInt::from(1_000_000u32);
324 let hundred = BigInt::from(100u8);
325 let one = BigInt::one();
326
327 if cnum == &one && nnum < &million {
328 return (ntext.to_string(), nnum.clone());
329 }
330 if nnum >= &million && cnum > &one {
331 return (format!("{} {}j", ctext, ntext), cnum + nnum);
332 }
333 if nnum == &hundred {
334 return (format!("{}{}", ctext, ntext), cnum + nnum);
335 }
336 (format!("{} {}", ctext, ntext), cnum + nnum)
337 }
338
339 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
340 self.verify_ordinal(value)?;
341 let word = self.to_cardinal(value)?;
342
343 // Python: `for src, repl in self.ords.items(): if word.endswith(src):
344 // return word[:-len(src)] + repl`
345 // `len(src)` is a CHAR count, but since `src` matched as a suffix the
346 // last `len(src)` chars are exactly `src` — so dropping the matched
347 // suffix's bytes is provably identical, and `strip_suffix` is safe on
348 // the multi-byte "naŭ" (3 chars / 4 bytes).
349 for &(src, repl) in ORDS {
350 if let Some(head) = word.strip_suffix(src) {
351 return Ok(format!("{}{}", head, repl));
352 }
353 }
354
355 // The "o"/"oj" arms are mutually exclusive and both reachable: a word
356 // ending "oj" does not end "o", so the elif is live (e.g.
357 // "dek milionoj" -> "dek miliona").
358 if let Some(head) = word.strip_suffix('o') {
359 Ok(format!("{}a", head))
360 } else if let Some(head) = word.strip_suffix("oj") {
361 Ok(format!("{}a", head))
362 } else {
363 Ok(format!("{}a", word))
364 }
365 }
366
367 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
368 self.verify_ordinal(value)?;
369 Ok(format!("{}a", value))
370 }
371
372 /// `to_ordinal(float/Decimal)`: EO's `to_ordinal` opens with
373 /// `verify_ordinal(value)`, so a fractional or negative value raises
374 /// TypeError; a whole non-negative one then rides the base `to_cardinal`
375 /// whole-value route into the integer ordinal — `5.0` → "kvina",
376 /// `Decimal("1E+2")` → "centa", `1e16` → "dek biliarda".
377 fn ordinal_float_entry(&self, value: &crate::floatpath::FloatValue) -> Result<String> {
378 let i = self.verify_ordinal_float(value)?;
379 self.to_ordinal(&i)
380 }
381
382 /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)`, then
383 /// `str(value) + "a"` — the repr verbatim, ".0" tails and all: `5.0` →
384 /// "5.0a", `Decimal("5.00")` → "5.00a", `-0.0` → "-0.0a"; `0.5`/`-1.0`
385 /// raise TypeError from the verify.
386 fn ordinal_num_float_entry(
387 &self,
388 value: &crate::floatpath::FloatValue,
389 repr_str: &str,
390 ) -> Result<String> {
391 self.verify_ordinal_float(value)?;
392 Ok(format!("{}a", repr_str))
393 }
394
395 // to_year is NOT overridden in Python; the trait default delegates to
396 // to_cardinal through &self, which is exactly right.
397
398 // ---- currency -------------------------------------------------------
399 //
400 // `Num2Word_EO` overrides only `CURRENCY_FORMS` and `pluralize`. Its
401 // `to_currency` override is a pure pass-through — it re-declares the
402 // signature to change `separator`'s default to " kaj", then calls
403 // `super().to_currency(...)` and returns the result untouched. That default
404 // already lives in `default_separator()` above and the trait resolves it
405 // before the body runs, so there is nothing left for a `to_currency`
406 // override to do here.
407 //
408 // Everything else comes from `Num2Word_Base` unchanged, and the trait
409 // defaults already mirror it:
410 // * `to_cheque`, `_money_verbose`, `_cents_verbose`, `_cents_terse`.
411 // * `CURRENCY_ADJECTIVES` is `{}` — the default `currency_adjective()`
412 // returns None, so `adjective=True` is a no-op for every code.
413 // * `CURRENCY_PRECISION` is `{}` — every code divides by 100. This is
414 // load-bearing for the corpus's JPY rows: with no precision entry the
415 // `divisor == 1` pre-rounding branch never fires, so JPY falls straight
416 // through to the forms lookup and raises NotImplementedError like any
417 // other unknown code.
418
419 fn lang_name(&self) -> &str {
420 "Num2Word_EO"
421 }
422
423 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
424 self.currency_forms.get(code)
425 }
426
427 /// `Num2Word_EO.pluralize`: `forms[0 if n <= 1 else 1]`.
428 ///
429 /// Note `n <= 1`, **not** EUR/EN's `n == 1`. The difference is observable at
430 /// zero and drives four corpus rows: `to_currency(0)` is "nul eŭro" (not
431 /// "nul eŭroj"), and `1.0` renders "unu eŭro kaj nul centimo" — a singular
432 /// subunit after "nul". Esperanto actually wants the plural after zero, so
433 /// this is a genuine Python bug; it is reproduced deliberately.
434 ///
435 /// Python indexes the tuple directly, so a one-form entry with `n > 1` would
436 /// raise IndexError. Every EO entry has two forms, making that unreachable —
437 /// but it is mapped to `Index` rather than a panic so the exception type
438 /// survives if the table ever changes.
439 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
440 let form = if n <= &BigInt::one() { 0 } else { 1 };
441 forms
442 .get(form)
443 .cloned()
444 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
445 }
446}