use crate::base::{Kwargs, Lang, N2WError, Result};
use crate::currency::{CurrencyForms, CurrencyValue};
use crate::floatpath::{default_to_cardinal_float, FloatValue};
use num_bigint::BigInt;
use num_integer::Integer;
use num_traits::{Signed, ToPrimitive, Zero};
use std::collections::HashMap;
const NEGWORD: &str = "minus ";
const ZERO: &str = "zero";
const ONES: [&str; 10] = [
"", "unus", "duo", "tres", "quattuor", "quinque", "sex", "septem", "octo", "novem",
];
const TENS: [&str; 10] = [
"",
"decem",
"viginti",
"triginta",
"quadraginta",
"quinquaginta",
"sexaginta",
"septuaginta",
"octoginta",
"nonaginta",
];
const HUNDRED: &str = "centum";
const THOUSAND: &str = "mille";
const MILLION: &str = "decies centena milia";
const SEPARATOR_UNSET: &str = ",";
const FALLBACK_CURRENCY: &str = "EUR";
fn digit(n: &BigInt) -> usize {
n.to_usize()
.filter(|d| *d < 10)
.expect("branch guard bounds this value to 0..=9")
}
fn parse_int(s: &str) -> Result<BigInt> {
s.parse::<BigInt>()
.map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
}
const CASES: [&str; 6] = ["nom", "gen", "dat", "acc", "abl", "voc"];
fn gender_idx(g: &str) -> Option<usize> {
match g {
"m" => Some(0),
"f" => Some(1),
"n" => Some(2),
_ => None,
}
}
fn case_idx(c: &str) -> Option<usize> {
CASES.iter().position(|&x| x == c)
}
const DECL_1: [[&str; 6]; 3] = [
["ūnus", "ūnīus", "ūnī", "ūnum", "ūnō", "ūne"],
["ūna", "ūnīus", "ūnī", "ūnam", "ūnā", "ūna"],
["ūnum", "ūnīus", "ūnī", "ūnum", "ūnō", "ūnum"],
];
const DECL_2: [[&str; 6]; 3] = [
["duo", "duōrum", "duōbus", "duōs", "duōbus", "duo"],
["duae", "duārum", "duābus", "duās", "duābus", "duae"],
["duo", "duōrum", "duōbus", "duo", "duōbus", "duo"],
];
const DECL_3: [[&str; 6]; 3] = [
["trēs", "trium", "tribus", "trēs", "tribus", "trēs"],
["trēs", "trium", "tribus", "trēs", "tribus", "trēs"],
["tria", "trium", "tribus", "tria", "tribus", "tria"],
];
const HUND_ENDING: [[&str; 6]; 3] = [
["ī", "ōrum", "īs", "ōs", "īs", "ī"],
["ae", "ārum", "īs", "ās", "īs", "ae"],
["a", "ōrum", "īs", "a", "īs", "a"],
];
const HUND_STEM: [&str; 10] = [
"",
"",
"ducent",
"trecent",
"quadringent",
"quīngent",
"sescent",
"septingent",
"octingent",
"nōngent",
];
const UNDER_20: [&str; 20] = [
"",
"",
"",
"",
"quattuor",
"quīnque",
"sex",
"septem",
"octō",
"novem",
"decem",
"ūndecim",
"duodecim",
"tredecim",
"quattuordecim",
"quīndecim",
"sēdecim",
"septendecim",
"duodēvīgintī",
"ūndēvīgintī",
];
const TENS_INV: [&str; 10] = [
"",
"",
"vīgintī",
"trīgintā",
"quadrāgintā",
"quīnquāgintā",
"sexāgintā",
"septuāgintā",
"octōgintā",
"nōnāgintā",
];
const MILIA: [&str; 6] = ["mīlia", "mīlium", "mīlibus", "mīlia", "mīlibus", "mīlia"];
const ZERO_DECL: [[&str; 6]; 3] = [
["nullus", "nullius", "nulli", "nullum", "nullo", "nulle"],
["nulla", "nullius", "nulli", "nullam", "nulla", "nulla"],
["nullum", "nullius", "nulli", "nullum", "nullo", "nullum"],
];
const ADJ_ENDING: [[&str; 6]; 3] = [
["us", "ī", "ō", "um", "ō", "e"],
["a", "ae", "ae", "am", "ā", "a"],
["um", "ī", "ō", "um", "ō", "um"],
];
const HIGH_UNITS: [(u64, &str, &str); 4] = [
(1_000_000_000_000_000, "billiardus", "billiardi"),
(1_000_000_000_000, "billio", "billiones"),
(1_000_000_000, "miliardus", "miliardi"),
(1_000_000, "milio", "miliones"),
];
const LA_MAXVAL_WORDS_EXP: u32 = 18;
fn apply_macrons(s: &str, macrons: bool) -> String {
if macrons {
return s.to_string();
}
s.chars()
.map(|c| match c {
'ā' => 'a',
'ē' => 'e',
'ī' => 'i',
'ō' => 'o',
'ū' => 'u',
'Ā' => 'A',
'Ē' => 'E',
'Ī' => 'I',
'Ō' => 'O',
'Ū' => 'U',
'ȳ' => 'y',
'Ȳ' => 'Y',
other => other,
})
.collect()
}
fn la_form(n: u32, g: usize, c: usize) -> &'static str {
match n {
1 => DECL_1[g][c],
2 => DECL_2[g][c],
3 => DECL_3[g][c],
_ => {
unreachable!("la_form only handles 1,2,3; hundreds via la_hundred")
}
}
}
fn la_hundred(h: usize, g: usize, c: usize) -> String {
format!("{}{}", HUND_STEM[h], HUND_ENDING[g][c])
}
fn la_low(n: u32, g: usize, c: usize) -> String {
match n {
0 => String::new(),
1 | 2 | 3 => la_form(n, g, c).to_string(),
_ => UNDER_20[n as usize].to_string(),
}
}
fn la_under_1000(n: u32, g: usize, c: usize) -> String {
let h = n / 100;
let rest = n % 100;
let t = rest / 10;
let u = rest % 10;
let mut parts: Vec<String> = Vec::new();
if h != 0 {
if h == 1 {
parts.push("centum".to_string());
} else {
parts.push(la_hundred(h as usize, g, c));
}
}
if rest == 0 {
} else if rest <= 19 {
parts.push(la_low(rest, g, c));
} else {
if t != 0 {
parts.push(TENS_INV[t as usize].to_string());
}
if u != 0 {
parts.push(la_low(u, g, c));
}
}
parts.join(" ")
}
fn la_zero(g: usize, c: usize) -> &'static str {
ZERO_DECL[g][c]
}
fn la_to_cardinal_pos(n: &BigInt, g: usize, c: usize) -> String {
if n.is_zero() {
return la_zero(g, c).to_string();
}
if n == &BigInt::from(1) {
return la_form(1, g, c).to_string();
}
let mut parts: Vec<String> = Vec::new();
let mut rest = n.clone();
for (power, sg, pl) in HIGH_UNITS.iter() {
let p = BigInt::from(*power);
let (count, r) = rest.div_rem(&p);
rest = r;
if count.is_zero() {
continue;
}
if count == BigInt::from(1) {
parts.push(format!("{} {}", DECL_1[0][0], sg));
} else {
let cnt = count.to_u32().expect("count < 1000 by construction");
parts.push(format!("{} {}", la_under_1000(cnt, 0, 0), pl));
}
}
let thousand = BigInt::from(1000);
let (thousands, r) = rest.div_rem(&thousand);
rest = r;
if thousands == BigInt::from(1) {
parts.push("mīlle".to_string());
} else if thousands >= BigInt::from(2) {
let th = thousands
.to_u32()
.expect("thousands < 1000 by construction");
parts.push(format!("{} {}", la_under_1000(th, 2, 0), MILIA[c]));
}
if rest > BigInt::zero() {
let rr = rest.to_u32().expect("remainder < 1000");
parts.push(la_under_1000(rr, g, c));
}
parts.join(" ")
}
fn la_ordinal_low(n: u32) -> Option<&'static str> {
let s = match n {
1 => "prīmus",
2 => "secundus",
3 => "tertius",
4 => "quārtus",
5 => "quīntus",
6 => "sextus",
7 => "septimus",
8 => "octāvus",
9 => "nōnus",
10 => "decimus",
11 => "ūndecimus",
12 => "duodecimus",
13 => "tertius decimus",
14 => "quārtus decimus",
15 => "quīntus decimus",
16 => "sextus decimus",
17 => "septimus decimus",
18 => "duodēvīcēsimus",
19 => "ūndēvīcēsimus",
20 => "vīcēsimus",
30 => "trīcēsimus",
40 => "quadrāgēsimus",
50 => "quīnquāgēsimus",
60 => "sexāgēsimus",
70 => "septuāgēsimus",
80 => "octōgēsimus",
90 => "nōnāgēsimus",
100 => "centēsimus",
200 => "ducentēsimus",
300 => "trecentēsimus",
400 => "quadringentēsimus",
500 => "quīngentēsimus",
600 => "sescentēsimus",
700 => "septingentēsimus",
800 => "octingentēsimus",
900 => "nōngentēsimus",
1000 => "mīllēsimus",
1_000_000 => "deciēs centiēs mīllēsimus",
_ => return None,
};
Some(s)
}
fn la_decline_adj(stem: &str, g: usize, c: usize) -> String {
if g == 0 && c == 0 {
return stem.to_string();
}
if let Some((head, tail)) = stem.split_once(' ') {
return format!(
"{} {}",
la_decline_adj(head, g, c),
la_decline_adj(tail, g, c)
);
}
let base = if stem.ends_with("us") || stem.ends_with("um") {
&stem[..stem.len() - 2]
} else {
return stem.to_string();
};
format!("{}{}", base, ADJ_ENDING[g][c])
}
const ROMAN_TABLE: [(u32, &str); 13] = [
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
];
fn la_to_roman(n: &BigInt) -> String {
let mut m = match n.to_u32() {
Some(v) if (1..4000).contains(&v) => v,
_ => return n.to_string(),
};
let mut out = String::new();
for (v, sym) in ROMAN_TABLE.iter() {
while m >= *v {
out.push_str(sym);
m -= *v;
}
}
out
}
fn la_normalize_grammar(gender: &str, case: &str) -> Result<(usize, usize)> {
let g = gender_idx(gender).ok_or_else(|| {
N2WError::Value(format!("gender must be 'm', 'f' or 'n'; got '{}'", gender))
})?;
let c = case_idx(case).ok_or_else(|| {
N2WError::Value(format!(
"case must be one of nom/gen/dat/acc/abl/voc; got '{}'",
case
))
})?;
Ok((g, c))
}
fn la_maxval_words() -> BigInt {
BigInt::from(10).pow(LA_MAXVAL_WORDS_EXP)
}
fn la_grammar_kw(kw: &Kwargs) -> Result<(&str, &str, bool)> {
if !kw.only(&["gender", "case", "macrons"]) {
return Err(N2WError::Type("got an unexpected keyword argument".into()));
}
let g = kw.str("gender").unwrap_or("m");
let c = kw.str("case").unwrap_or("nom");
let m = kw.bool("macrons").unwrap_or(true);
Ok((g, c, m))
}
fn la_cardinal_int(value: &BigInt, gender: &str, case: &str, macrons: bool) -> Result<String> {
let (g, c) = la_normalize_grammar(gender, case)?;
if value.is_negative() {
let inner = la_cardinal_int(&(-value), gender, case, true)?;
return Ok(apply_macrons(
&format!("{} {}", NEGWORD.trim(), inner),
macrons,
));
}
let maxw = la_maxval_words();
if value >= &maxw {
return Err(N2WError::Overflow(format!(
"abs({}) maius esse nōn dēbet quam {}.",
value, maxw
)));
}
Ok(apply_macrons(&la_to_cardinal_pos(value, g, c), macrons))
}
fn la_ordinal_int(value: &BigInt, gender: &str, case: &str, macrons: bool) -> Result<String> {
if value.is_negative() {
return Err(N2WError::Type(format!(
"Numerus negātīvus {} ōrdinālis fierī nōn potest.",
value
)));
}
if let Some(v) = value.to_u32() {
if let Some(stem) = la_ordinal_low(v) {
let (g, c) = la_normalize_grammar(gender, case)?;
return Ok(apply_macrons(&la_decline_adj(stem, g, c), macrons));
}
}
la_cardinal_int(value, gender, case, macrons)
}
fn la_ordinal_num_int(value: &BigInt) -> Result<String> {
if value.is_negative() {
return Err(N2WError::Type(format!(
"Numerus negātīvus {} ōrdinālis fierī nōn potest.",
value
)));
}
Ok(la_to_roman(value))
}
fn la_year_int(value: &BigInt, macrons: bool) -> Result<String> {
if value.is_negative() {
let body = format!(
"ante Chrīstum {}",
la_cardinal_int(&(-value), "m", "nom", true)?
);
Ok(apply_macrons(&body, macrons))
} else {
la_cardinal_int(value, "m", "nom", macrons)
}
}
pub struct LangLa {
currency_forms: HashMap<&'static str, CurrencyForms>,
eur: CurrencyForms,
}
impl Default for LangLa {
fn default() -> Self {
Self::new()
}
}
impl LangLa {
pub fn new() -> Self {
let eur = CurrencyForms::new(&["euro", "euros"], &["centesima", "centesimae"]);
let currency_forms: HashMap<&'static str, CurrencyForms> = [
(FALLBACK_CURRENCY, eur.clone()),
(
"USD",
CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
),
]
.into_iter()
.collect();
LangLa {
currency_forms,
eur,
}
}
fn split_value(val: &CurrencyValue) -> Result<(BigInt, u32, bool)> {
match val {
CurrencyValue::Int(v) => Ok((v.abs(), 0, v.is_negative())),
CurrencyValue::Decimal { value: d, .. } => {
let is_negative = d.is_negative();
let (digits, scale) = d.as_bigint_and_exponent();
let digits = digits.abs();
if scale < 0 {
return Err(N2WError::Value(format!(
"invalid literal for int() with base 10: '{}'",
d.abs()
)));
}
if scale == 0 {
return Ok((digits, 0, is_negative));
}
let pow = BigInt::from(10u32).pow(scale as u32);
let (left, frac) = digits.div_rem(&pow);
let right = (frac * BigInt::from(100u32) / &pow).to_u32().unwrap_or(0);
Ok((left, right, is_negative))
}
}
}
fn int_to_word(&self, number: &BigInt) -> String {
if number.is_zero() {
return ZERO.to_string();
}
if number.is_negative() {
return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
}
if *number < BigInt::from(10) {
return ONES[digit(number)].to_string();
}
if *number < BigInt::from(100) {
let (tens_val, ones_val) = number.div_mod_floor(&BigInt::from(10));
if ones_val.is_zero() {
return TENS[digit(&tens_val)].to_string();
}
return format!("{} {}", TENS[digit(&tens_val)], ONES[digit(&ones_val)]);
}
if *number < BigInt::from(1000) {
let (hundreds_val, remainder) = number.div_mod_floor(&BigInt::from(100));
let mut result = format!("{} {}", ONES[digit(&hundreds_val)], HUNDRED);
if !remainder.is_zero() {
result.push(' ');
result.push_str(&self.int_to_word(&remainder));
}
return result;
}
if *number < BigInt::from(1_000_000) {
let (thousands_val, remainder) = number.div_mod_floor(&BigInt::from(1000));
let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
if !remainder.is_zero() {
result.push(' ');
result.push_str(&self.int_to_word(&remainder));
}
return result;
}
if *number < BigInt::from(1_000_000_000) {
let (millions_val, remainder) = number.div_mod_floor(&BigInt::from(1_000_000));
let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
if !remainder.is_zero() {
result.push(' ');
result.push_str(&self.int_to_word(&remainder));
}
return result;
}
number.to_string()
}
}
impl Lang for LangLa {
fn cardinal_float_entry(
&self,
value: &crate::floatpath::FloatValue,
precision_override: Option<u32>,
) -> crate::base::Result<String> {
if let Some(i) = value.as_whole_int() {
return self.to_cardinal(&i);
}
default_to_cardinal_float(self, value, precision_override)
}
fn to_cardinal_float_kw(
&self,
value: &FloatValue,
precision_override: Option<u32>,
kw: &Kwargs,
) -> Result<String> {
if !kw.only(&["gender", "case", "macrons"]) {
return Err(N2WError::Type("got an unexpected keyword argument".into()));
}
let macrons = kw.bool("macrons").unwrap_or(true);
let s = self.cardinal_float_entry(value, precision_override)?;
Ok(apply_macrons(&s, macrons))
}
fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
match value.as_whole_int() {
Some(i) if !i.is_negative() => self.to_ordinal(&i),
Some(_) => Err(N2WError::Type(
"Numerus negātīvus ōrdinālis fierī nōn potest.".into(),
)),
None => Err(N2WError::Type(
"Numerus frāctus ōrdinālis fierī nōn potest.".into(),
)),
}
}
fn ordinal_num_float_entry(&self, value: &FloatValue, _repr_str: &str) -> Result<String> {
match value.as_whole_int() {
Some(i) if !i.is_negative() => Ok(la_to_roman(&i)),
Some(_) => Err(N2WError::Type(
"Numerus negātīvus ōrdinālis fierī nōn potest.".into(),
)),
None => Err(N2WError::Type(
"Numerus frāctus ōrdinālis fierī nōn potest.".into(),
)),
}
}
fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
match crate::strnum::python_decimal_parse(s)? {
crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
"invalid literal for int() with base 10: 'Infinity'".into(),
)),
p => Ok(p),
}
}
fn default_currency(&self) -> &str {
"EUR"
}
fn default_separator(&self) -> &str {
" "
}
fn negword(&self) -> &str {
NEGWORD
}
fn pointword(&self) -> &str {
"virgula"
}
fn maxval(&self) -> &BigInt {
static MV: std::sync::OnceLock<BigInt> = std::sync::OnceLock::new();
MV.get_or_init(|| BigInt::from(10).pow(LA_MAXVAL_WORDS_EXP))
}
fn to_cardinal(&self, value: &BigInt) -> Result<String> {
la_cardinal_int(value, "m", "nom", true)
}
fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
let (g, c, m) = la_grammar_kw(kw)?;
la_cardinal_int(value, g, c, m)
}
fn to_ordinal(&self, value: &BigInt) -> Result<String> {
la_ordinal_int(value, "m", "nom", true)
}
fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
let (g, c, m) = la_grammar_kw(kw)?;
la_ordinal_int(value, g, c, m)
}
fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
la_ordinal_num_int(value)
}
fn to_year(&self, value: &BigInt) -> Result<String> {
la_year_int(value, true)
}
fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
if !kw.only(&["macrons"]) {
return Err(N2WError::Type(
"to_year() got an unexpected keyword argument".into(),
));
}
la_year_int(value, kw.bool("macrons").unwrap_or(true))
}
fn lang_name(&self) -> &str {
"Num2Word_LA"
}
fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
self.currency_forms.get(code)
}
fn to_currency(
&self,
val: &CurrencyValue,
currency: &str,
cents: bool,
separator: Option<&str>,
adjective: bool,
) -> Result<String> {
let separator = separator.unwrap_or(self.default_separator());
let _ = adjective;
let (left, right, is_negative) = Self::split_value(val)?;
let forms = self.currency_forms.get(currency).unwrap_or(&self.eur);
let (cr1, cr2) = (&forms.unit, &forms.subunit);
let one = BigInt::from(1u32);
let left_str = self.int_to_word(&left);
let mut result = format!(
"{} {}",
left_str,
if left == one { &cr1[0] } else { &cr1[1] }
);
if cents && right != 0 {
let cents_str = self.int_to_word(&BigInt::from(right));
let separator = if separator == SEPARATOR_UNSET {
" "
} else {
separator
};
result.push_str(separator);
result.push_str(¢s_str);
result.push(' ');
result.push_str(if right == 1 { &cr2[0] } else { &cr2[1] });
}
if is_negative {
result.insert_str(0, NEGWORD);
}
Ok(result.trim().to_string())
}
}
#[cfg(test)]
mod la_pr657_tests {
use super::*;
use crate::base::Kwargs;
fn card(v: i64) -> String {
LangLa::new().to_cardinal(&BigInt::from(v)).unwrap()
}
fn ordn(v: i64) -> String {
LangLa::new().to_ordinal_num(&BigInt::from(v)).unwrap()
}
#[test]
fn cardinals_decline_with_macrons() {
assert_eq!(card(0), "nullus");
assert_eq!(card(1), "ūnus");
assert_eq!(card(50), "quīnquāgintā");
assert_eq!(card(100), "centum");
assert_eq!(card(200), "ducentī");
assert_eq!(card(2024), "duo mīlia vīgintī quattuor");
assert_eq!(
LangLa::new()
.to_cardinal(&BigInt::from(38_630_666))
.unwrap(),
"trīgintā octō miliones sescenta trīgintā mīlia sescentī sexāgintā sex"
);
}
#[test]
fn gender_and_case_kwargs() {
let mut kw = Kwargs::default();
kw.0.push(("gender".into(), crate::base::KwVal::Str("f".into())));
kw.0.push(("case".into(), crate::base::KwVal::Str("abl".into())));
assert_eq!(
LangLa::new().to_cardinal_kw(&BigInt::from(1), &kw).unwrap(),
"ūnā"
);
}
#[test]
fn macrons_false_strips_diacritics() {
let mut kw = Kwargs::default();
kw.0.push(("macrons".into(), crate::base::KwVal::Bool(false)));
assert_eq!(
LangLa::new()
.to_cardinal_kw(&BigInt::from(50), &kw)
.unwrap(),
"quinquaginta"
);
}
#[test]
fn ordinal_num_is_roman() {
assert_eq!(ordn(4), "IV");
assert_eq!(ordn(2024), "MMXXIV");
assert_eq!(ordn(0), "0");
}
#[test]
fn ordinals_decline() {
assert_eq!(
LangLa::new().to_ordinal(&BigInt::from(3)).unwrap(),
"tertius"
);
assert_eq!(
LangLa::new().to_ordinal(&BigInt::from(20)).unwrap(),
"vīcēsimus"
);
}
#[test]
fn year_bc_prefix() {
assert_eq!(
LangLa::new().to_year(&BigInt::from(-44)).unwrap(),
"ante Chrīstum quadrāgintā quattuor"
);
}
#[test]
fn overflow_past_1e18() {
let big = BigInt::from(10).pow(18);
assert!(matches!(
LangLa::new().to_cardinal(&big),
Err(N2WError::Overflow(_))
));
}
}