use crate::base::{Lang, N2WError, Result};
use crate::currency::{CurrencyForms, CurrencyValue};
use crate::floatpath::FloatValue;
use bigdecimal::BigDecimal;
use num_bigint::BigInt;
use num_traits::{One, Signed, ToPrimitive, Zero};
use std::collections::HashMap;
use std::str::FromStr;
const ONES: [&str; 10] = [
"", "emu", "bbiri", "ssatu", "nnya", "ttaano", "mukaaga", "musanvu", "munaana", "mwenda",
];
const TENS: [&str; 10] = [
"", "kkumi", "abiri", "asatu", "ana", "ataano", "nkaaga", "nsanvu", "kinaana", "kyenda",
];
const ZERO_WORD: &str = "nuli";
const HUNDRED: &str = "kikumi";
const THOUSAND: &str = "lukumi";
const MILLION: &str = "kakadde";
const POINTWORD: &str = "akatonnyeze";
const NEGWORD: &str = "wansi wa ";
const JOIN: &str = " mu ";
fn int_to_word(number: &BigInt) -> String {
if number.is_zero() {
return ZERO_WORD.to_string();
}
let billion = BigInt::from(1_000_000_000u32);
if number >= &billion {
return number.to_string();
}
let n = number
.to_u64()
.expect("0 < number < 10^9 was just established");
int_to_word_small(n)
}
fn int_to_word_small(n: u64) -> String {
if n == 0 {
return ZERO_WORD.to_string();
}
if n < 10 {
return ONES[n as usize].to_string();
}
if n < 100 {
let (t, o) = (n / 10, n % 10);
let mut s = TENS[t as usize].to_string();
if o != 0 {
s.push_str(JOIN);
s.push_str(ONES[o as usize]);
}
return s;
}
if n < 1000 {
let (h, r) = (n / 100, n % 100);
let mut s = String::new();
if h > 1 {
s.push_str(ONES[h as usize]);
s.push(' ');
}
s.push_str(HUNDRED);
if r != 0 {
s.push_str(JOIN);
s.push_str(&int_to_word_small(r));
}
return s;
}
if n < 1_000_000 {
let (t, r) = (n / 1000, n % 1000);
let mut s = String::new();
if t > 1 {
s.push_str(&int_to_word_small(t));
s.push(' ');
}
s.push_str(THOUSAND);
if r != 0 {
s.push_str(JOIN);
s.push_str(&int_to_word_small(r));
}
return s;
}
let (m, r) = (n / 1_000_000, n % 1_000_000);
let mut s = String::new();
if m > 1 {
s.push_str(&int_to_word_small(m));
s.push(' ');
}
s.push_str(MILLION);
if r != 0 {
s.push_str(JOIN);
s.push_str(&int_to_word_small(r));
}
s
}
fn py_str(digits: &BigInt, scale: i64) -> String {
debug_assert!(!digits.is_negative(), "py_str expects abs(value)'s digits");
let ds = digits.to_string();
let decpt = ds.len() as i64 - scale;
if decpt > 16 || decpt < -3 {
let mantissa = if ds.len() > 1 {
format!("{}.{}", &ds[..1], &ds[1..])
} else {
ds
};
let exp = decpt - 1;
let sign = if exp < 0 { '-' } else { '+' };
return format!("{}e{}{:02}", mantissa, sign, exp.abs());
}
if decpt <= 0 {
format!("0.{}{}", "0".repeat((-decpt) as usize), ds)
} else if decpt as usize >= ds.len() {
format!("{}{}.0", ds, "0".repeat(decpt as usize - ds.len()))
} else {
format!("{}.{}", &ds[..decpt as usize], &ds[decpt as usize..])
}
}
fn py_int(s: &str) -> Result<BigInt> {
BigInt::from_str(s)
.map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
}
fn cardinal_from_pystr(n: &str) -> Result<String> {
let n = n.trim();
if let Some(rest) = n.strip_prefix('-') {
let inner = cardinal_from_pystr(rest)?;
return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
}
match n.split_once('.') {
Some((left, right)) => {
let left_int = py_int(left)?;
let mut ret = format!("{} {}", int_to_word(&left_int), POINTWORD);
for ch in right.chars() {
ret.push(' ');
ret.push_str(ones_or_nuli(ch)?);
}
Ok(ret.trim().to_string())
}
None => Ok(int_to_word(&py_int(n)?)),
}
}
fn ones_or_nuli(ch: char) -> Result<&'static str> {
let d = py_int(&ch.to_string())?;
let idx = d
.to_usize()
.expect("a single decimal digit is 0..=9, always a valid usize");
Ok(if ONES[idx].is_empty() {
ZERO_WORD
} else {
ONES[idx]
})
}
fn py_float_str(f: f64) -> String {
let neg = f.is_sign_negative();
let sci = format!("{:e}", f.abs()); let (mant, exp_str) = sci.split_once('e').expect("{:e} always emits an 'e'");
let exp: i64 = exp_str.parse().expect("{:e} exponent is a base-10 integer");
let (int_part, frac_part) = mant.split_once('.').unwrap_or((mant, ""));
let digits = BigInt::from_str(&format!("{}{}", int_part, frac_part))
.expect("mantissa digits are ASCII 0-9");
let scale = frac_part.len() as i64 - exp;
let body = py_str(&digits, scale);
if neg {
format!("-{}", body)
} else {
body
}
}
fn py_decimal_str(value: &BigDecimal) -> String {
let neg = value.is_negative();
let (coeff, scale) = value.abs().as_bigint_and_exponent();
let int_str = coeff.to_string(); let ndigits = int_str.len() as i64;
let exp = -scale; let leftdigits = exp + ndigits;
let dotplace = if exp <= 0 && leftdigits > -6 {
leftdigits
} else {
1 };
let (intpart, fracpart) = if dotplace <= 0 {
(
"0".to_string(),
format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
)
} else if dotplace >= ndigits {
(
format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
String::new(),
)
} else {
let dp = dotplace as usize;
(int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
};
let exp_part = if leftdigits == dotplace {
String::new()
} else {
format!("E{:+}", leftdigits - dotplace)
};
let body = format!("{}{}{}", intpart, fracpart, exp_part);
if neg {
format!("-{}", body)
} else {
body
}
}
fn pick_form(forms: &[String], n: &BigInt) -> Result<String> {
let idx = if n.is_one() { 0 } else { 1 };
forms
.get(idx)
.cloned()
.ok_or_else(|| N2WError::Index("tuple index out of range".into()))
}
fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
const SENTI: [&str; 2] = ["senti", "senti"];
let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
m.insert("UGX", CurrencyForms::new(&["shilingi", "shilingi"], &SENTI));
m.insert("USD", CurrencyForms::new(&["doola", "doola"], &SENTI));
m.insert("EUR", CurrencyForms::new(&["yuro", "yuro"], &SENTI));
m
}
pub struct LangLg {
currency_forms: HashMap<&'static str, CurrencyForms>,
fallback_forms: CurrencyForms,
}
impl Default for LangLg {
fn default() -> Self {
Self::new()
}
}
impl LangLg {
pub fn new() -> Self {
LangLg {
currency_forms: build_currency_forms(),
fallback_forms: CurrencyForms::new(&["shilingi", "shilingi"], &["senti", "senti"]),
}
}
}
impl Lang for LangLg {
fn cardinal_float_entry(
&self,
value: &crate::floatpath::FloatValue,
precision_override: Option<u32>,
) -> crate::base::Result<String> {
self.to_cardinal_float(value, precision_override)
}
fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
let cardinal = self.cardinal_float_entry(value, None)?;
Ok(format!("ow' {}", cardinal))
}
fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
Ok(format!("ow' {}", repr_str))
}
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 {
"UGX"
}
fn default_separator(&self) -> &str {
" "
}
fn negword(&self) -> &str {
NEGWORD
}
fn pointword(&self) -> &str {
"akatonnyeze"
}
fn to_cardinal(&self, value: &BigInt) -> Result<String> {
if value.is_negative() {
let inner = int_to_word(&value.abs());
return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
}
Ok(int_to_word(value))
}
fn to_ordinal(&self, value: &BigInt) -> Result<String> {
Ok(format!("ow' {}", self.to_cardinal(value)?))
}
fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
Ok(format!("ow' {}", value))
}
fn to_year(&self, value: &BigInt) -> Result<String> {
self.to_cardinal(value)
}
fn to_cardinal_float(
&self,
value: &FloatValue,
_precision_override: Option<u32>,
) -> Result<String> {
let n = match value {
FloatValue::Float { value, .. } => py_float_str(*value),
FloatValue::Decimal { value, .. } => py_decimal_str(value),
};
cardinal_from_pystr(&n)
}
fn lang_name(&self) -> &str {
"Num2Word_LG"
}
fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
self.currency_forms.get(code)
}
fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
if forms.is_empty() {
return Ok(String::new());
}
Ok(if n.is_one() {
forms[0].clone()
} else {
forms[forms.len() - 1].clone()
})
}
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 is_negative = val.is_negative();
let (left, right) = match val {
CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
CurrencyValue::Decimal { value, .. } => {
let (digits, scale) = value.abs().as_bigint_and_exponent();
let s = py_str(&digits, scale);
let mut parts = s.splitn(2, '.');
let p0 = parts.next().unwrap_or("");
let p1 = parts.next();
let left = if p0.is_empty() {
BigInt::zero()
} else {
py_int(p0)?
};
let right = match p1 {
Some(f) if !f.is_empty() => {
let head: String = f.chars().take(2).collect();
py_int(&format!("{:0<2}", head))?
}
_ => BigInt::zero(),
};
(left, right)
}
};
let forms = self
.currency_forms
.get(currency)
.unwrap_or(&self.fallback_forms);
let mut result = format!("{} {}", int_to_word(&left), pick_form(&forms.unit, &left)?);
if cents && !right.is_zero() {
result.push_str(separator);
result.push_str(&int_to_word(&right));
result.push(' ');
result.push_str(&pick_form(&forms.subunit, &right)?);
}
if is_negative {
result = format!("{}{}", NEGWORD, result);
}
Ok(result.trim().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use bigdecimal::BigDecimal;
#[test]
fn bigdecimal_does_not_normalise() {
for (s, want_digits, want_scale) in [
("1.0", "10", 1i64),
("0.5", "5", 1),
("0.01", "1", 2),
("12.34", "1234", 2),
("1234.56", "123456", 2),
("1.50", "150", 2),
("1e+16", "1", -16),
("1e+21", "1", -21),
("1.25e+20", "125", -18),
("1e-05", "1", 5),
("1000000000000000.0", "10000000000000000", 1),
] {
let (d, sc) = BigDecimal::from_str(s).unwrap().as_bigint_and_exponent();
assert_eq!(
(d.to_string().as_str(), sc),
(want_digits, want_scale),
"{s}"
);
}
}
#[test]
fn py_str_round_trips_repr() {
for s in [
"0.5",
"0.01",
"1.0",
"12.34",
"99.99",
"1234.56",
"100.0",
"0.0001",
"1000000000000000.0",
"1e+16",
"1.5e+16",
"1e+21",
"1.25e+20",
"1.5e+20",
"1e-05",
"1.5e-05",
"1e+100",
"0.0",
] {
let (d, sc) = BigDecimal::from_str(s).unwrap().as_bigint_and_exponent();
assert_eq!(py_str(&d, sc), s);
}
}
fn currency(v: &str, is_int: bool, code: &str) -> Result<String> {
let val = CurrencyValue::parse(v, is_int, !is_int, !is_int).unwrap();
LangLg::new().to_currency(&val, code, true, None, false)
}
#[test]
fn to_currency_matches_corpus() {
for (arg, is_int, code, want) in [
("0", true, "EUR", "nuli yuro"),
("1", true, "EUR", "emu yuro"),
("100", true, "EUR", "kikumi yuro"),
("1000000", true, "EUR", "kakadde yuro"),
(
"12.34",
false,
"EUR",
"kkumi mu bbiri yuro asatu mu nnya senti",
),
("0.01", false, "EUR", "nuli yuro emu senti"),
("1.0", false, "EUR", "emu yuro"),
("0.5", false, "EUR", "nuli yuro ataano senti"),
(
"-12.34",
false,
"EUR",
"wansi wa kkumi mu bbiri yuro asatu mu nnya senti",
),
(
"1234.56",
false,
"USD",
"lukumi mu bbiri kikumi mu asatu mu nnya doola ataano mu mukaaga senti",
),
("1", true, "GBP", "emu shilingi"),
(
"12.34",
false,
"JPY",
"kkumi mu bbiri shilingi asatu mu nnya senti",
),
("0.5", false, "KWD", "nuli shilingi ataano senti"),
("1", true, "ZZZ", "emu shilingi"),
] {
assert_eq!(currency(arg, is_int, code).unwrap(), want, "{arg} {code}");
}
}
#[test]
fn to_currency_reproduces_repr_slicing_bugs() {
for arg in ["1e+16", "1e+21", "1.5e+20", "1e-05", "1.5e-05"] {
match currency(arg, false, "EUR") {
Err(N2WError::Value(_)) => {}
other => panic!("{arg}: expected ValueError, got {other:?}"),
}
}
assert_eq!(
currency("1.25e+20", false, "EUR").unwrap(),
"emu yuro abiri mu ttaano senti"
);
assert_eq!(
currency("10000000000000000", true, "EUR").unwrap(),
"10000000000000000 yuro"
);
}
fn card_f(f: f64) -> Result<String> {
LangLg::new().to_cardinal_float(
&FloatValue::Float {
value: f,
precision: 0,
},
None,
)
}
fn card_d(s: &str) -> Result<String> {
let v = FloatValue::Decimal {
value: BigDecimal::from_str(s).unwrap(),
precision: 0,
};
LangLg::new().to_cardinal_float(&v, None)
}
#[test]
fn to_cardinal_float_matches_corpus() {
for (f, want) in [
(0.0, "nuli akatonnyeze nuli"),
(0.5, "nuli akatonnyeze ttaano"),
(1.0, "emu akatonnyeze nuli"),
(1.5, "emu akatonnyeze ttaano"),
(2.25, "bbiri akatonnyeze bbiri ttaano"),
(3.14, "ssatu akatonnyeze emu nnya"),
(0.01, "nuli akatonnyeze nuli emu"),
(0.1, "nuli akatonnyeze emu"),
(0.99, "nuli akatonnyeze mwenda mwenda"),
(1.01, "emu akatonnyeze nuli emu"),
(12.34, "kkumi mu bbiri akatonnyeze ssatu nnya"),
(99.99, "kyenda mu mwenda akatonnyeze mwenda mwenda"),
(100.5, "kikumi akatonnyeze ttaano"),
(
1234.56,
"lukumi mu bbiri kikumi mu asatu mu nnya akatonnyeze ttaano mukaaga",
),
(-0.5, "wansi wa nuli akatonnyeze ttaano"),
(-1.5, "wansi wa emu akatonnyeze ttaano"),
(-12.34, "wansi wa kkumi mu bbiri akatonnyeze ssatu nnya"),
(1.005, "emu akatonnyeze nuli nuli ttaano"),
(2.675, "bbiri akatonnyeze mukaaga musanvu ttaano"),
] {
assert_eq!(card_f(f).unwrap(), want, "{f}");
}
}
#[test]
fn to_cardinal_float_negative_zero() {
assert_eq!(card_f(-0.0).unwrap(), "wansi wa nuli akatonnyeze nuli");
}
#[test]
fn to_cardinal_float_exponential_raises() {
for f in [1e16, 1e21, 1e-5, 1.5e20, 1.25e20] {
match card_f(f) {
Err(N2WError::Value(_)) => {}
other => panic!("{f}: expected ValueError, got {other:?}"),
}
}
}
#[test]
fn to_cardinal_decimal_matches_corpus() {
for (s, want) in [
("0.01", "nuli akatonnyeze nuli emu"),
("1.10", "emu akatonnyeze emu nuli"),
("12.345", "kkumi mu bbiri akatonnyeze ssatu nnya ttaano"),
(
"98746251323029.99",
"98746251323029 akatonnyeze mwenda mwenda",
),
("0.001", "nuli akatonnyeze nuli nuli emu"),
("5", "ttaano"),
("0.0", "nuli akatonnyeze nuli"),
] {
assert_eq!(card_d(s).unwrap(), want, "{s}");
}
}
#[test]
fn py_decimal_str_follows_decimal_rule() {
for (s, want) in [
("0.01", "0.01"),
("1.10", "1.10"),
("98746251323029.99", "98746251323029.99"),
("5", "5"),
("0.0", "0.0"),
("0.00001", "0.00001"),
("1E-7", "1E-7"),
("1E+2", "1E+2"),
] {
let d = BigDecimal::from_str(s).unwrap();
assert_eq!(py_decimal_str(&d), want, "{s}");
}
}
#[test]
fn to_cheque_matches_corpus() {
let lg = LangLg::new();
let v = BigDecimal::from_str("1234.56").unwrap();
assert_eq!(
lg.to_cheque(&v, "EUR").unwrap(),
"LUKUMI MU BBIRI KIKUMI MU ASATU MU NNYA AND 56/100 YURO"
);
assert_eq!(
lg.to_cheque(&v, "USD").unwrap(),
"LUKUMI MU BBIRI KIKUMI MU ASATU MU NNYA AND 56/100 DOOLA"
);
match lg.to_cheque(&v, "GBP") {
Err(N2WError::NotImplemented(m)) => {
assert_eq!(
m,
"Currency code \"GBP\" not implemented for \"Num2Word_LG\""
)
}
other => panic!("expected NotImplementedError, got {other:?}"),
}
assert_eq!(currency("1", true, "GBP").unwrap(), "emu shilingi");
}
}