use bigdecimal::num_traits::FromPrimitive;
use bigdecimal::BigDecimal;
use num_bigint::BigInt;
use std::collections::HashMap;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum W2nError {
Words2Num(String),
NotImplemented(String),
Key(String),
}
impl W2nError {
pub fn message(&self) -> String {
match self {
W2nError::Words2Num(m) | W2nError::NotImplemented(m) => m.clone(),
W2nError::Key(k) => py_repr_str(k),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum W2nValue {
Int(BigInt),
Float(f64),
Dec(BigDecimal),
}
impl W2nValue {
pub fn py_str(&self) -> String {
match self {
W2nValue::Int(i) => i.to_string(),
W2nValue::Float(f) => py_float_repr(*f),
W2nValue::Dec(d) => {
let (digits, scale) = d.as_bigint_and_exponent();
py_decimal_str(&digits, scale)
}
}
}
pub fn py_repr(&self) -> String {
match self {
W2nValue::Dec(_) => format!("Decimal('{}')", self.py_str()),
_ => self.py_str(),
}
}
fn is_unit_magnitude(&self) -> bool {
match self {
W2nValue::Int(i) => {
let one = BigInt::from(1);
*i == one || *i == -one
}
W2nValue::Float(f) => *f == 1.0 || *f == -1.0,
W2nValue::Dec(d) => {
let one = BigDecimal::from(1);
*d == one || *d == -one
}
}
}
}
fn en_to_sentence_value(v: crate::w2n_lang_en::W2nValue) -> W2nValue {
use crate::w2n_lang_en::W2nValue as E;
match v {
E::Int(i) => W2nValue::Int(i),
E::Float(f) => W2nValue::Float(f),
E::Dec(d) => W2nValue::Dec(d.to_bigdecimal()),
}
}
pub fn py_repr_str(s: &str) -> String {
let quote = if s.contains('\'') && !s.contains('"') {
'"'
} else {
'\''
};
let mut out = String::with_capacity(s.len() + 2);
out.push(quote);
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c == quote => {
out.push('\\');
out.push(c);
}
c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
out.push_str(&format!("\\x{:02x}", c as u32));
}
c => out.push(c),
}
}
out.push(quote);
out
}
pub fn py_float_repr(v: f64) -> String {
if v.is_nan() {
return "nan".to_string();
}
if v.is_infinite() {
return if v > 0.0 { "inf" } else { "-inf" }.to_string();
}
let sci = format!("{:e}", v); let (mant, exp) = sci.split_once('e').expect("{:e} always emits an exponent");
let exp: i32 = exp.parse().expect("{:e} exponent is an integer");
let neg = mant.starts_with('-');
let mant = mant.trim_start_matches('-');
let digits: String = mant.chars().filter(|c| *c != '.').collect();
let decpt = exp + 1;
let sign = if neg { "-" } else { "" };
let nd = digits.len() as i32;
if decpt <= -4 || decpt > 16 {
let mut m = String::from(&digits[..1]);
if nd > 1 {
m.push('.');
m.push_str(&digits[1..]);
}
let e = decpt - 1;
let (esign, eabs) = if e < 0 { ("-", -e) } else { ("+", e) };
return format!("{}{}e{}{:02}", sign, m, esign, eabs);
}
if decpt <= 0 {
format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
} else if decpt >= nd {
format!(
"{}{}{}.0",
sign,
digits,
"0".repeat((decpt - nd) as usize)
)
} else {
format!(
"{}{}.{}",
sign,
&digits[..decpt as usize],
&digits[decpt as usize..]
)
}
}
pub fn py_decimal_str(digits: &BigInt, scale: i64) -> String {
let neg = digits.sign() == num_bigint::Sign::Minus;
let int_str = digits.magnitude().to_string(); let exp: i64 = -scale; let len_int = int_str.chars().count() as i64;
let leftdigits = exp + len_int;
let dotplace = if exp <= 0 && leftdigits > -6 {
leftdigits
} else {
1
};
let (intpart, fracpart) = if dotplace <= 0 {
(
String::from("0"),
format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
)
} else if dotplace >= len_int {
(
format!("{}{}", int_str, "0".repeat((dotplace - len_int) as usize)),
String::new(),
)
} else {
let cut = dotplace as usize;
(int_str[..cut].to_string(), format!(".{}", &int_str[cut..]))
};
let expo = if leftdigits == dotplace {
String::new()
} else {
format!("E{:+}", leftdigits - dotplace)
};
format!(
"{}{}{}{}",
if neg { "-" } else { "" },
intpart,
fracpart,
expo
)
}
const ND_BLOCKS: [u32; 65] = [
0x0030, 0x0660, 0x06F0, 0x07C0, 0x0966, 0x09E6, 0x0A66, 0x0AE6, 0x0B66, 0x0BE6, 0x0C66, 0x0CE6,
0x0D66, 0x0DE6, 0x0E50, 0x0ED0, 0x0F20, 0x1040, 0x1090, 0x17E0, 0x1810, 0x1946, 0x19D0, 0x1A80,
0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0,
0xFF10, 0x104A0, 0x10D30, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0,
0x11650, 0x116C0, 0x11730, 0x118E0, 0x11950, 0x11C50, 0x11D50, 0x11DA0, 0x16A60, 0x16B50,
0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6, 0x1E140, 0x1E2F0, 0x1E950, 0x1FBF0,
];
pub fn digit_value(c: char) -> Option<u32> {
let cp = c as u32;
match ND_BLOCKS.binary_search(&cp) {
Ok(_) => Some(0),
Err(0) => None,
Err(i) => {
let start = ND_BLOCKS[i - 1];
if cp - start < 10 {
Some(cp - start)
} else {
None
}
}
}
}
pub fn is_unicode_digit(c: char) -> bool {
digit_value(c).is_some()
}
fn to_ascii_digits(s: &str) -> String {
s.chars()
.map(|c| match digit_value(c) {
Some(v) if !c.is_ascii_digit() => char::from_digit(v, 10).unwrap_or(c),
_ => c,
})
.collect()
}
pub fn py_is_space(c: char) -> bool {
c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
}
fn py_str_isspace(s: &str) -> bool {
!s.is_empty() && s.chars().all(py_is_space)
}
fn py_strip(s: &str) -> &str {
s.trim_matches(py_is_space)
}
fn py_split_whitespace(s: &str) -> Vec<&str> {
s.split(py_is_space).filter(|p| !p.is_empty()).collect()
}
fn py_rsplit_once_ws(s: &str) -> Vec<&str> {
let chars: Vec<(usize, char)> = s.char_indices().collect();
let mut end = chars.len();
while end > 0 && py_is_space(chars[end - 1].1) {
end -= 1;
}
if end == 0 {
return Vec::new();
}
let mut tail_start = end;
while tail_start > 0 && !py_is_space(chars[tail_start - 1].1) {
tail_start -= 1;
}
let byte_end = if end == chars.len() {
s.len()
} else {
chars[end].0
};
let tail = &s[chars[tail_start].0..byte_end];
let mut head_end = tail_start;
while head_end > 0 && py_is_space(chars[head_end - 1].1) {
head_end -= 1;
}
if head_end == 0 {
return vec![tail];
}
vec![&s[..chars[head_end].0], tail]
}
fn py_rstrip_char(s: &str, ch: char) -> &str {
s.trim_end_matches(ch)
}
fn rstrip_punct(s: &str) -> &str {
s.trim_end_matches(['.', ',', ';', ':', '!', '?', '"', '\''])
}
fn trailing_punct(s: &str) -> &str {
let kept = rstrip_punct(s);
&s[kept.len()..]
}
fn ends_with_terminal_punct(s: &str) -> bool {
matches!(
s.chars().next_back(),
Some('.') | Some(',') | Some(';') | Some(':') | Some('!') | Some('?')
)
}
fn tokenize(sentence: &str) -> Vec<String> {
let chars: Vec<char> = sentence.chars().collect();
let mut parts = Vec::new();
let mut i = 0;
while i < chars.len() {
let space = py_is_space(chars[i]);
let start = i;
while i < chars.len() && py_is_space(chars[i]) == space {
i += 1;
}
parts.push(chars[start..i].iter().collect());
}
parts
}
pub const CONVERTER_LANGS: [&str; 120] = [
"af", "am", "ar", "as", "az", "ba", "be", "bg", "bn", "bo", "br", "bs", "ca", "ce", "cs", "cy",
"da", "de", "el", "en", "en_IN", "en_NG", "eo", "es", "es_CO", "es_CR", "es_GT", "es_NI",
"es_VE", "et", "eu", "fa", "fi", "fo", "fr", "fr_BE", "fr_CH", "fr_DZ", "gl", "gu", "ha", "haw",
"he", "hi", "hr", "ht", "hu", "hy", "id", "is", "it", "ja", "jw", "ka", "kk", "km", "kn", "ko",
"kz", "la", "lb", "ln", "lo", "lt", "lv", "mg", "mi", "mk", "ml", "mn", "mr", "ms", "mt", "my",
"ne", "nl", "nn", "no", "oc", "pa", "pl", "ps", "pt", "pt_BR", "ro", "ru", "sa", "sd", "si",
"sk", "sl", "sn", "so", "sq", "sr", "su", "sv", "sw", "ta", "te", "tet", "tg", "th", "tk", "tl",
"tr", "tt", "uk", "ur", "uz", "vi", "wo", "yi", "yo", "zh", "zh_CN", "zh_HK", "zh_TW", "jp",
"cn",
];
fn is_known_lang(k: &str) -> bool {
CONVERTER_LANGS.contains(&k)
}
pub fn resolve_lang(lang: &str) -> Result<String, W2nError> {
if is_known_lang(lang) {
return Ok(lang.to_string());
}
let normalized = lang.replace('-', "_");
if is_known_lang(&normalized) {
return Ok(normalized);
}
let parts: Vec<&str> = normalized.split('_').collect();
if parts.len() >= 2 {
let candidate = format!("{}_{}", parts[0].to_lowercase(), parts[1].to_uppercase());
if is_known_lang(&candidate) {
return Ok(candidate);
}
if is_known_lang(parts[0]) {
return Ok(parts[0].to_string());
}
}
let two: String = normalized.chars().take(2).collect();
if is_known_lang(&two) {
return Ok(two);
}
Err(W2nError::NotImplemented(format!(
"language {} is not supported",
py_repr_str(lang)
)))
}
const BASE_NEGATIVE_WORDS: [&str; 2] = ["minus", "negative"];
enum Converter {
En,
Table(&'static str),
}
fn converter_for(resolved: &str) -> Converter {
match resolved {
"en" => Converter::En,
"zh" | "cn" => Converter::Table("zh_CN"),
"jp" => Converter::Table("ja"),
other => {
let key = CONVERTER_LANGS
.iter()
.copied()
.find(|k| *k == other)
.unwrap_or("en");
Converter::Table(key)
}
}
}
impl Converter {
fn is_number_word(&self, token: &str) -> bool {
self.to_cardinal(token).is_ok()
}
fn to_cardinal(&self, text: &str) -> Result<W2nValue, W2nError> {
match self {
Converter::En => en_convert(crate::en_to_cardinal(text)),
Converter::Table(lang) => base_convert(lang, text, false),
}
}
fn to_ordinal(&self, text: &str) -> Result<W2nValue, W2nError> {
match self {
Converter::En => en_convert(crate::en_to_ordinal(text)),
Converter::Table(lang) => base_convert(lang, text, true),
}
}
fn to_year(&self, text: &str) -> Result<W2nValue, W2nError> {
match self {
Converter::En => en_convert(crate::en_to_year(text)),
Converter::Table(lang) => base_convert(lang, text, false),
}
}
fn to_ordinal_num(&self, text: &str) -> Result<W2nValue, W2nError> {
base_ordinal_num(text)
}
fn to_currency(&self, text: &str) -> Result<W2nValue, W2nError> {
self.to_cardinal(text)
}
fn convert(&self, to: &str, text: &str, has_kwargs: bool) -> Result<W2nValue, W2nError> {
if has_kwargs {
return Err(W2nError::Words2Num(
"unexpected keyword argument".to_string(),
));
}
match to {
"cardinal" => self.to_cardinal(text),
"ordinal" => self.to_ordinal(text),
"year" => self.to_year(text),
"ordinal_num" => self.to_ordinal_num(text),
"currency" => self.to_currency(text),
_ => Err(W2nError::NotImplemented(format!(
"'Words2Num' object has no attribute 'to_{}'",
to
))),
}
}
}
fn en_convert(
r: Result<crate::w2n_lang_en::W2nValue, crate::w2n_lang_en::W2nError>,
) -> Result<W2nValue, W2nError> {
match r {
Ok(v) => Ok(en_to_sentence_value(v)),
Err(e) => Err(W2nError::Words2Num(e.msg)),
}
}
fn base_convert(lang: &str, text: &str, ordinal: bool) -> Result<W2nValue, W2nError> {
if crate::supported_langs().contains(&lang) {
let neg = [
BASE_NEGATIVE_WORDS[0].to_string(),
BASE_NEGATIVE_WORDS[1].to_string(),
];
if let Ok(Some(v)) = crate::lookup(lang, text, ordinal, &neg) {
return Ok(W2nValue::Int(BigInt::from(v)));
}
}
parse_literal(text)
}
fn parse_literal(text: &str) -> Result<W2nValue, W2nError> {
let normalized = crate::normalize(text);
let unparseable = |s: &str| W2nError::Words2Num(format!("cannot parse {} as a number", py_repr_str(s)));
if normalized.is_empty() {
return Err(unparseable(&normalized));
}
for neg in BASE_NEGATIVE_WORDS {
if let Some(rest) = normalized.strip_prefix(&format!("{} ", neg)) {
return finish_parse_literal(rest, -1);
}
if normalized == neg {
return Err(unparseable(&normalized));
}
}
finish_parse_literal(&normalized, 1)
}
fn finish_parse_literal(normalized: &str, sign: i64) -> Result<W2nValue, W2nError> {
if normalized.contains('.') {
if let Some(f) = py_float(normalized) {
return Ok(W2nValue::Float(if sign < 0 { -f } else { f }));
}
} else if let Some(i) = py_int(normalized) {
return Ok(W2nValue::Int(if sign < 0 { -i } else { i }));
}
Err(W2nError::Words2Num(format!(
"cannot parse {} as a number",
py_repr_str(normalized)
)))
}
fn base_ordinal_num(text: &str) -> Result<W2nValue, W2nError> {
let chars: Vec<char> = text.chars().collect();
for i in 0..chars.len() {
if chars[i] == '-' && chars.get(i + 1).copied().is_some_and(is_unicode_digit) {
let mut j = i + 1;
while j < chars.len() && is_unicode_digit(chars[j]) {
j += 1;
}
let group: String = chars[i..j].iter().collect();
return py_int(&group)
.map(W2nValue::Int)
.ok_or_else(|| W2nError::Words2Num(unparseable_msg(text)));
}
if is_unicode_digit(chars[i]) {
let mut j = i;
while j < chars.len() && is_unicode_digit(chars[j]) {
j += 1;
}
let group: String = chars[i..j].iter().collect();
return py_int(&group)
.map(W2nValue::Int)
.ok_or_else(|| W2nError::Words2Num(unparseable_msg(text)));
}
}
Err(W2nError::Words2Num(unparseable_msg(text)))
}
fn unparseable_msg(text: &str) -> String {
format!("cannot parse {} as a number", py_repr_str(text))
}
fn py_int(s: &str) -> Option<BigInt> {
let t = s.trim_matches(py_is_space);
let (neg, body) = match t.strip_prefix('-') {
Some(r) => (true, r),
None => (false, t.strip_prefix('+').unwrap_or(t)),
};
if body.is_empty() || !body.chars().all(is_unicode_digit) {
return None;
}
let ascii = to_ascii_digits(body);
let v = BigInt::from_str(&ascii).ok()?;
Some(if neg { -v } else { v })
}
fn py_float(s: &str) -> Option<f64> {
let t = s.trim_matches(py_is_space);
let ascii = to_ascii_digits(t);
ascii.parse::<f64>().ok()
}
fn call_words2num(text: &str, lang: &str) -> Result<W2nValue, W2nError> {
let resolved = resolve_lang(lang)?;
converter_for(&resolved).to_cardinal(text)
}
pub fn words2num(
text: &str,
lang: &str,
to: &str,
) -> Result<crate::w2n_lang_en::W2nValue, W2nError> {
let resolved = resolve_lang(lang)?;
let converter = converter_for(&resolved);
const CONVERTER_TYPES: [&str; 5] = ["cardinal", "ordinal", "ordinal_num", "year", "currency"];
if !CONVERTER_TYPES.contains(&to) {
return Err(W2nError::NotImplemented(format!(
"conversion type {} unsupported",
py_repr_str(to)
)));
}
match &converter {
Converter::En => match to {
"cardinal" | "currency" => {
crate::en_to_cardinal(text).map_err(|e| W2nError::Words2Num(e.msg))
}
"ordinal" => crate::en_to_ordinal(text).map_err(|e| W2nError::Words2Num(e.msg)),
"year" => crate::en_to_year(text).map_err(|e| W2nError::Words2Num(e.msg)),
_ => base_ordinal_num(text).map(sentence_to_en_value),
},
Converter::Table(_) => {
let v = match to {
"cardinal" | "year" | "currency" => converter.to_cardinal(text),
"ordinal" => converter.to_ordinal(text),
_ => converter.to_ordinal_num(text),
}?;
Ok(sentence_to_en_value(v))
}
}
}
fn sentence_to_en_value(v: W2nValue) -> crate::w2n_lang_en::W2nValue {
use crate::w2n_lang_en::W2nValue as EnV;
match v {
W2nValue::Int(i) => EnV::Int(i),
W2nValue::Float(f) => EnV::Float(f),
W2nValue::Dec(d) => EnV::Dec(crate::w2n_lang_en::PyDec::from_bigdecimal(&d)),
}
}
fn starts_run(converter: &Converter, token: &str) -> bool {
if token.is_empty() {
return false;
}
let dehyphened = token.replace('-', " ");
py_split_whitespace(&dehyphened)
.iter()
.any(|sub| converter.is_number_word(sub))
}
fn is_candidate(converter: &Converter, token: &str, includable: &[&str]) -> bool {
if token.is_empty() {
return false;
}
if includable.contains(&token) {
return true;
}
let dehyphened = token.replace('-', " ");
py_split_whitespace(&dehyphened)
.iter()
.any(|sub| converter.is_number_word(sub))
}
const INCLUDABLE_EN: [&str; 7] = ["and", "point", "dot", "minus", "negative", "a", "an"];
pub fn words2num_sentence(
sentence: &str,
lang: &str,
to: &str,
has_kwargs: bool,
) -> Result<String, W2nError> {
let resolved = resolve_lang(lang)?;
let converter = converter_for(&resolved);
let includable: &[&str] = if resolved == "en" { &INCLUDABLE_EN } else { &[] };
let parts = tokenize(sentence);
let n = parts.len();
let mut out = String::new();
let mut i = 0usize;
while i < n {
let piece = &parts[i];
if py_str_isspace(piece) {
out.push_str(piece);
i += 1;
continue;
}
let head = rstrip_punct(piece).to_lowercase();
if !starts_run(&converter, &head) {
out.push_str(piece);
i += 1;
continue;
}
let mut best_value: Option<W2nValue> = None;
let mut best_end = i;
let mut j = i;
while j < n {
let tok = &parts[j];
if py_str_isspace(tok) {
j += 1;
continue;
}
let clean = rstrip_punct(tok).to_lowercase();
if !is_candidate(&converter, &clean, includable) {
break;
}
let run = parts[i..=j].concat();
let stripped = rstrip_punct(py_strip(&run)).to_string();
if let Ok(v) = converter.convert(to, &stripped, has_kwargs) {
best_value = Some(v);
best_end = j;
}
if ends_with_terminal_punct(tok) {
break;
}
j += 1;
}
if let Some(v) = best_value {
let run = parts[i..=best_end].concat();
let trailing = trailing_punct(&run);
out.push_str(&v.py_str());
out.push_str(trailing);
i = best_end + 1;
} else {
out.push_str(piece);
i += 1;
}
}
Ok(out)
}
pub fn convert_sentence(
sentence: &str,
lang: &str,
to: &str,
has_kwargs: bool,
) -> Result<String, W2nError> {
words2num_sentence(sentence, lang, to, has_kwargs)
}
pub fn sentence_to_words(
sentence: &str,
lang: &str,
to: &str,
has_kwargs: bool,
) -> Result<String, W2nError> {
words2num_sentence(sentence, lang, to, has_kwargs)
}
#[derive(Debug, Clone, PartialEq)]
pub struct UnitInfo {
pub short: &'static str,
pub long: &'static str,
pub kind: &'static str,
pub confidence: f64,
}
const fn ui(short: &'static str, long: &'static str, kind: &'static str, confidence: f64) -> UnitInfo {
UnitInfo {
short,
long,
kind,
confidence,
}
}
pub static UNITS: &[(&str, UnitInfo)] = &[
("mm", ui("mm", "millimeter", "length", 1.0)),
("cm", ui("cm", "centimeter", "length", 1.0)),
("dm", ui("dm", "decimeter", "length", 1.0)),
("m", ui("m", "meter", "length", 0.6)),
("km", ui("km", "kilometer", "length", 1.0)),
("in", ui("in", "inch", "length", 0.5)),
("ft", ui("ft", "foot", "length", 1.0)),
("yd", ui("yd", "yard", "length", 1.0)),
("mi", ui("mi", "mile", "length", 1.0)),
("nm", ui("nm", "nanometer", "length", 1.0)),
("\u{b5}m", ui("\u{b5}m", "micrometer", "length", 1.0)),
("um", ui("\u{b5}m", "micrometer", "length", 1.0)),
("mg", ui("mg", "milligram", "mass", 1.0)),
("g", ui("g", "gram", "mass", 0.6)),
("kg", ui("kg", "kilogram", "mass", 1.0)),
("t", ui("t", "tonne", "mass", 0.5)),
("lb", ui("lb", "pound", "mass", 1.0)),
("lbs", ui("lb", "pound", "mass", 1.0)),
("oz", ui("oz", "ounce", "mass", 1.0)),
("\u{b0}", ui("\u{b0}", "degree", "temperature", 0.7)),
("\u{b0}C", ui("\u{b0}C", "degree celsius", "temperature", 1.0)),
("\u{b0}F", ui("\u{b0}F", "degree fahrenheit", "temperature", 1.0)),
("K", ui("K", "kelvin", "temperature", 0.6)),
("C", ui("\u{b0}C", "degree celsius", "temperature", 0.5)),
("F", ui("\u{b0}F", "degree fahrenheit", "temperature", 0.5)),
("ms", ui("ms", "millisecond", "time", 1.0)),
("s", ui("s", "second", "time", 0.6)),
("sec", ui("s", "second", "time", 1.0)),
("min", ui("min", "minute", "time", 1.0)),
("h", ui("h", "hour", "time", 0.7)),
("hr", ui("h", "hour", "time", 1.0)),
("hrs", ui("h", "hour", "time", 1.0)),
("d", ui("d", "day", "time", 0.5)),
("ml", ui("ml", "milliliter", "volume", 1.0)),
("cl", ui("cl", "centiliter", "volume", 1.0)),
("dl", ui("dl", "deciliter", "volume", 1.0)),
("l", ui("L", "liter", "volume", 0.7)),
("L", ui("L", "liter", "volume", 1.0)),
("gal", ui("gal", "gallon", "volume", 1.0)),
("%", ui("%", "percent", "percent", 1.0)),
];
pub fn units_get(key: &str) -> Option<&'static UnitInfo> {
UNITS.iter().find(|(k, _)| *k == key).map(|(_, v)| v)
}
#[derive(Debug, Clone, PartialEq)]
pub struct CurrencyInfo {
pub code: &'static str,
pub symbol: &'static str,
pub long: &'static str,
}
const fn ci(code: &'static str, symbol: &'static str, long: &'static str) -> CurrencyInfo {
CurrencyInfo { code, symbol, long }
}
pub static CURRENCIES: &[(&str, CurrencyInfo)] = &[
("$", ci("USD", "$", "dollar")),
("\u{20ac}", ci("EUR", "\u{20ac}", "euro")),
("\u{a3}", ci("GBP", "\u{a3}", "pound")),
("\u{a5}", ci("JPY", "\u{a5}", "yen")),
("\u{20b9}", ci("INR", "\u{20b9}", "rupee")),
("\u{20bd}", ci("RUB", "\u{20bd}", "ruble")),
("\u{20a9}", ci("KRW", "\u{20a9}", "won")),
("\u{20ba}", ci("TRY", "\u{20ba}", "lira")),
("USD", ci("USD", "$", "US dollar")),
("EUR", ci("EUR", "\u{20ac}", "euro")),
("GBP", ci("GBP", "\u{a3}", "pound sterling")),
("JPY", ci("JPY", "\u{a5}", "yen")),
("CHF", ci("CHF", "CHF", "Swiss franc")),
("CAD", ci("CAD", "$", "Canadian dollar")),
("AUD", ci("AUD", "$", "Australian dollar")),
("CNY", ci("CNY", "\u{a5}", "yuan")),
("INR", ci("INR", "\u{20b9}", "rupee")),
("BRL", ci("BRL", "R$", "real")),
("MXN", ci("MXN", "$", "Mexican peso")),
("RUB", ci("RUB", "\u{20bd}", "ruble")),
("KRW", ci("KRW", "\u{20a9}", "won")),
];
pub fn currencies_get(key: &str) -> Option<&'static CurrencyInfo> {
CURRENCIES.iter().find(|(k, _)| *k == key).map(|(_, v)| v)
}
const CURRENCY_SYMBOLS: [char; 8] = [
'$',
'\u{20ac}',
'\u{a3}',
'\u{a5}',
'\u{20b9}',
'\u{20bd}',
'\u{20a9}',
'\u{20ba}',
];
fn is_currency_symbol(c: char) -> bool {
CURRENCY_SYMBOLS.contains(&c)
}
fn scale_suffix(s: &str) -> Option<BigInt> {
let pow = |n: u32| BigInt::from(10u8).pow(n);
match s {
"k" | "K" => Some(pow(3)),
"m" | "M" => Some(pow(6)),
"b" | "B" | "bn" => Some(pow(9)),
"t" | "T" | "tn" => Some(pow(12)),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Quantity {
pub value: W2nValue,
pub unit: Option<String>,
pub unit_long: Option<String>,
pub kind: Option<String>,
pub confidence: f64,
pub raw: String,
}
impl Quantity {
fn bare(value: W2nValue, raw: &str) -> Quantity {
Quantity {
value,
unit: None,
unit_long: None,
kind: None,
confidence: 1.0,
raw: raw.to_string(),
}
}
pub fn py_repr(&self) -> String {
match &self.unit {
Some(u) if !u.is_empty() => format!(
"Quantity(value={}, unit={}, kind={}, confidence={})",
self.value.py_repr(),
py_repr_str(u),
match &self.kind {
Some(k) => py_repr_str(k),
None => "None".to_string(),
},
py_float_repr(self.confidence),
),
_ => format!("Quantity(value={})", self.value.py_repr()),
}
}
}
const NUMBER_FORMAT_DEFAULTS: &[(&str, &str, &str)] = &[
("_default", ",", "."),
("en", ",", "."),
("en_GB", ",", "."),
("en_IN", ",", "."),
("en_NG", ",", "."),
("zh", ",", "."),
("zh_CN", ",", "."),
("zh_HK", ",", "."),
("zh_TW", ",", "."),
("ja", ",", "."),
("ko", ",", "."),
("th", ",", "."),
("vi", ".", ","),
("fr", " ", ","),
("fr_BE", " ", ","),
("fr_DZ", " ", ","),
("fr_CH", "'", "."),
("de", ".", ","),
("es", ".", ","),
("es_CO", ".", ","),
("es_CR", ".", ","),
("es_GT", ".", ","),
("es_NI", ".", ","),
("es_VE", ".", ","),
("it", ".", ","),
("pt", ".", ","),
("pt_BR", ".", ","),
("nl", ".", ","),
("ro", ".", ","),
("hr", ".", ","),
("sl", ".", ","),
("sr", ".", ","),
("tr", ".", ","),
("el", ".", ","),
("ru", " ", ","),
("uk", " ", ","),
("be", " ", ","),
("bg", " ", ","),
("pl", " ", ","),
("cs", " ", ","),
("sk", " ", ","),
("hu", " ", ","),
("sv", " ", ","),
("no", " ", ","),
("nn", " ", ","),
("da", ".", ","),
("fi", " ", ","),
("et", " ", ","),
("lt", " ", ","),
("lv", " ", ","),
("is", ".", ","),
("fo", ".", ","),
("ar", ",", "."),
("fa", ",", "."),
("he", ",", "."),
];
fn get_format(lang: &str) -> (&'static str, &'static str) {
let find = |k: &str| {
NUMBER_FORMAT_DEFAULTS
.iter()
.find(|(n, _, _)| *n == k)
.map(|(_, t, d)| (*t, *d))
};
if let Some(f) = find(lang) {
return f;
}
let base = lang.split('_').next().unwrap_or("");
if !lang.is_empty() {
if let Some(f) = find(base) {
return f;
}
}
find("_default").expect("_default is always present")
}
fn to_number(s: &str) -> Result<W2nValue, W2nError> {
let ok = {
let mut it = s.chars().peekable();
let mut n_int = 0usize;
while it.peek().copied().is_some_and(is_unicode_digit) {
it.next();
n_int += 1;
}
let mut good = n_int > 0;
if good && it.peek() == Some(&'.') {
it.next();
let mut n_frac = 0usize;
while it.peek().copied().is_some_and(is_unicode_digit) {
it.next();
n_frac += 1;
}
good = n_frac > 0;
}
good && it.next().is_none()
};
if !ok {
return Err(W2nError::Words2Num(format!(
"not a parseable number: {}",
py_repr_str(s)
)));
}
let ascii = to_ascii_digits(s);
if ascii.contains('.') {
Ok(W2nValue::Float(ascii.parse::<f64>().map_err(|e| {
W2nError::Words2Num(e.to_string())
})?))
} else {
Ok(W2nValue::Int(BigInt::from_str(&ascii).map_err(|e| {
W2nError::Words2Num(e.to_string())
})?))
}
}
fn strip_space_like(s: &str) -> String {
s.chars()
.filter(|c| !(py_is_space(*c) || matches!(c, '\u{a0}' | '\u{202f}' | '\u{2009}')))
.collect()
}
fn parse_with_explicit(
s: &str,
thousands_sep: Option<&str>,
decimal_sep: Option<&str>,
) -> Result<W2nValue, W2nError> {
let mut s = s.to_string();
if let Some(t) = thousands_sep.filter(|t| !t.is_empty()) {
if matches!(t, " " | "\u{a0}" | "\u{202f}" | "\u{2009}") {
s = strip_space_like(&s);
} else {
s = s.replace(t, "");
}
}
if let Some(d) = decimal_sep.filter(|d| !d.is_empty()) {
if d != "." {
if s.contains('.') {
s = s.replace('.', "");
}
s = s.replace(d, ".");
}
}
to_number(&s)
}
fn resolve_single_sep(s: &str, sep: char) -> String {
let parts: Vec<&str> = s.split(sep).collect();
if parts.len() > 2 {
return s.replace(sep, "");
}
let after = parts[1]; let n_after = after.chars().count();
if n_after == 3 && !parts[0].is_empty() && parts[0].chars().count() <= 3 {
if sep == '.' {
return s.to_string();
}
return s.replace(',', ".");
}
if n_after != 3 {
return if sep == '.' {
s.to_string()
} else {
s.replace(',', ".")
};
}
s.replace(sep, "")
}
fn auto_detect_parse(s: &str) -> Result<W2nValue, W2nError> {
let s2: String = s
.chars()
.filter(|c| {
!(py_is_space(*c) || matches!(c, '\u{a0}' | '\u{202f}' | '\u{2009}' | '\'' | '_'))
})
.collect();
let has_comma = s2.contains(',');
let has_dot = s2.contains('.');
if has_comma && has_dot {
let last_comma = s2.rfind(',');
let last_dot = s2.rfind('.');
let s3 = if last_comma > last_dot {
s2.replace('.', "").replace(',', ".")
} else {
s2.replace(',', "")
};
return to_number(&s3);
}
if has_comma {
return to_number(&resolve_single_sep(&s2, ','));
}
if has_dot {
return to_number(&resolve_single_sep(&s2, '.'));
}
to_number(&s2)
}
fn apply_sign(sign: i32, v: W2nValue) -> W2nValue {
if sign == 1 {
return v;
}
match v {
W2nValue::Int(i) => W2nValue::Int(-i),
W2nValue::Float(f) => W2nValue::Float(-f),
W2nValue::Dec(d) => W2nValue::Dec(-d),
}
}
fn parse_number_string(
s: &str,
thousands_sep: Option<&str>,
decimal_sep: Option<&str>,
lang: Option<&str>,
) -> Result<W2nValue, W2nError> {
let s = py_strip(s);
if s.is_empty() {
return Err(W2nError::Words2Num("empty numeric string".to_string()));
}
let mut sign = 1i32;
let mut rest = s;
let first = s.chars().next().unwrap();
if first == '+' || first == '-' {
if first == '-' {
sign = -1;
}
rest = py_strip_start(&s[first.len_utf8()..]);
}
if rest.is_empty() {
return Err(W2nError::Words2Num(
"empty numeric string after sign".to_string(),
));
}
if thousands_sep.is_some() || decimal_sep.is_some() {
return Ok(apply_sign(
sign,
parse_with_explicit(rest, thousands_sep, decimal_sep)?,
));
}
if let Some(l) = lang {
let (t, d) = get_format(l);
match parse_with_explicit(rest, Some(t), Some(d)) {
Ok(v) => return Ok(apply_sign(sign, v)),
Err(W2nError::Words2Num(_)) => {} Err(e) => return Err(e),
}
}
Ok(apply_sign(sign, auto_detect_parse(rest)?))
}
fn py_strip_start(s: &str) -> &str {
s.trim_start_matches(py_is_space)
}
fn is_num_class(c: char) -> bool {
is_unicode_digit(c)
|| matches!(
c,
'.' | ',' | '\'' | '\u{a0}' | '\u{202f}' | '\u{2009}' | ' ' | '_'
)
}
fn is_num_sep(c: char) -> bool {
matches!(
c,
'.' | ',' | '\'' | '_' | '\u{a0}' | '\u{202f}' | '\u{2009}' | ' '
)
}
struct Cursor<'a> {
c: &'a [char],
i: usize,
}
impl<'a> Cursor<'a> {
fn new(c: &'a [char]) -> Self {
Cursor { c, i: 0 }
}
fn peek(&self) -> Option<char> {
self.c.get(self.i).copied()
}
fn at(&self, k: usize) -> Option<char> {
self.c.get(self.i + k).copied()
}
fn eat_if(&mut self, f: impl Fn(char) -> bool) -> bool {
if self.peek().is_some_and(&f) {
self.i += 1;
true
} else {
false
}
}
fn skip_ws(&mut self) {
while self.eat_if(py_is_space) {}
}
fn take_while(&mut self, f: impl Fn(char) -> bool) -> String {
let start = self.i;
while self.peek().is_some_and(&f) {
self.i += 1;
}
self.c[start..self.i].iter().collect()
}
fn at_end(&self) -> bool {
self.i >= self.c.len()
}
}
fn eat_signed_num_class(cur: &mut Cursor) -> Option<String> {
let start = cur.i;
cur.eat_if(|c| c == '-' || c == '+');
let body = cur.take_while(is_num_class);
if body.is_empty() {
cur.i = start;
return None;
}
Some(cur.c[start..cur.i].iter().collect())
}
fn eat_iso_code(cur: &mut Cursor) -> Option<String> {
let mut out = String::new();
for k in 0..3 {
match cur.at(k) {
Some(c) if c.is_ascii_uppercase() => out.push(c),
_ => return None,
}
}
cur.i += 3;
Some(out)
}
fn try_currency_prefix(text: &[char]) -> Option<(String, String, Option<String>)> {
{
let mut cur = Cursor::new(text);
cur.skip_ws();
if let Some(sym) = cur.peek().filter(|c| is_currency_symbol(*c)) {
cur.i += 1;
cur.skip_ws();
if let Some(num) = eat_signed_num_class(&mut cur) {
cur.skip_ws();
let mut scale = None;
if cur.peek().is_some_and(|c| "kKmMbBtT".contains(c)) {
let mut s = String::new();
s.push(cur.peek().unwrap());
cur.i += 1;
if cur.peek().is_some_and(|c| c == 'n' || c == 'N') {
s.push(cur.peek().unwrap());
cur.i += 1;
}
scale = Some(s);
}
cur.skip_ws();
if cur.at_end() {
return Some((sym.to_string(), py_strip(&num).to_string(), scale));
}
}
}
}
{
let mut cur = Cursor::new(text);
cur.skip_ws();
if let Some(code) = eat_iso_code(&mut cur) {
cur.skip_ws();
if let Some(num) = eat_signed_num_class(&mut cur) {
cur.skip_ws();
if cur.at_end() && currencies_get(&code).is_some() {
return Some((code, py_strip(&num).to_string(), None));
}
}
}
}
None
}
fn try_currency_suffix(text: &[char]) -> Option<(String, String)> {
{
let mut cur = Cursor::new(text);
cur.skip_ws();
if let Some(num) = eat_signed_num_class(&mut cur) {
cur.skip_ws();
if let Some(sym) = cur.peek().filter(|c| is_currency_symbol(*c)) {
cur.i += 1;
cur.skip_ws();
if cur.at_end() {
return Some((sym.to_string(), py_strip(&num).to_string()));
}
}
}
}
{
let mut cur = Cursor::new(text);
cur.skip_ws();
if let Some(num) = eat_signed_num_class(&mut cur) {
cur.skip_ws();
if let Some(code) = eat_iso_code(&mut cur) {
cur.skip_ws();
if cur.at_end() && currencies_get(&code).is_some() {
return Some((code, py_strip(&num).to_string()));
}
}
}
}
None
}
fn quantity_currency(value: W2nValue, sym: &str, raw: &str) -> Quantity {
let info = currencies_get(sym).expect("every symbol the regex matches is a CURRENCIES key");
Quantity {
value,
unit: Some(info.code.to_string()),
unit_long: Some(info.long.to_string()),
kind: Some("currency".to_string()),
confidence: 1.0,
raw: raw.to_string(),
}
}
fn apply_scale(value: W2nValue, scale_str: &str) -> Result<W2nValue, W2nError> {
let multiplier =
scale_suffix(scale_str).ok_or_else(|| W2nError::Key(scale_str.to_string()))?;
match value {
W2nValue::Int(i) => Ok(W2nValue::Int(i * multiplier)),
W2nValue::Float(f) => Ok(W2nValue::Float(f * bigint_to_f64(&multiplier))),
W2nValue::Dec(d) => Ok(W2nValue::Float(
bigdecimal_to_f64(&d) * bigint_to_f64(&multiplier),
)),
}
}
fn bigint_to_f64(i: &BigInt) -> f64 {
i.to_string().parse::<f64>().unwrap_or(f64::INFINITY)
}
fn bigdecimal_to_f64(d: &BigDecimal) -> f64 {
d.to_string().parse::<f64>().unwrap_or(f64::INFINITY)
}
fn resolve_unit(
unit_str: &str,
prefer: &HashMap<String, String>,
) -> Option<(&'static UnitInfo, Option<&'static str>, f64)> {
let info = units_get(unit_str).or_else(|| units_get(&unit_str.to_lowercase()))?;
if let Some(target) = prefer.get(unit_str).filter(|t| !t.is_empty()) {
for (_, u) in UNITS.iter() {
if u.long == target || u.short == target {
return Some((info, Some(u.long), info.confidence.max(0.9)));
}
}
}
Some((info, None, info.confidence))
}
fn try_digit_unit(
text: &[char],
prefer: &HashMap<String, String>,
thousands_sep: Option<&str>,
decimal_sep: Option<&str>,
lang: &str,
) -> Result<Option<Quantity>, W2nError> {
let mut cur = Cursor::new(text);
cur.skip_ws();
let Some(num) = eat_signed_num_class(&mut cur) else {
return Ok(None);
};
cur.skip_ws();
let unit: String = if cur.peek() == Some('\u{b0}') {
let mut s = String::from('\u{b0}');
cur.i += 1;
if let Some(c) = cur.peek().filter(|c| *c == 'C' || *c == 'F') {
s.push(c);
cur.i += 1;
}
s
} else if cur.peek() == Some('\u{b5}') && cur.at(1) == Some('m') {
cur.i += 2;
String::from("\u{b5}m")
} else if cur.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
cur.take_while(|c| c.is_ascii_alphabetic())
} else if cur.peek() == Some('%') {
cur.i += 1;
String::from("%")
} else {
return Ok(None);
};
cur.skip_ws();
if !cur.at_end() {
return Ok(None);
}
let num_str = py_strip(&num).to_string();
let unit_str = py_strip(&unit).to_string();
let Some((info, alt_long, conf)) = resolve_unit(&unit_str, prefer) else {
return Ok(None);
};
let value = match parse_number_string(&num_str, thousands_sep, decimal_sep, Some(lang)) {
Ok(v) => v,
Err(W2nError::Words2Num(_)) => return Ok(None),
Err(e) => return Err(e),
};
Ok(Some(Quantity {
value,
unit: Some(info.short.to_string()),
unit_long: Some(alt_long.unwrap_or(info.long).to_string()),
kind: Some(info.kind.to_string()),
confidence: conf,
raw: String::new(),
}))
}
fn word_unit(tail: &str) -> Option<(&'static str, &'static str)> {
Some(match tail {
"millimeter" | "millimetre" => ("mm", "length"),
"centimeter" | "centimetre" => ("cm", "length"),
"meter" | "metre" => ("m", "length"),
"kilometer" | "kilometre" => ("km", "length"),
"inch" => ("in", "length"),
"foot" | "feet" => ("ft", "length"),
"yard" => ("yd", "length"),
"mile" => ("mi", "length"),
"milligram" => ("mg", "mass"),
"gram" => ("g", "mass"),
"kilogram" => ("kg", "mass"),
"tonne" | "ton" => ("t", "mass"),
"pound" => ("lb", "mass"),
"ounce" => ("oz", "mass"),
"second" => ("s", "time"),
"minute" => ("min", "time"),
"hour" => ("h", "time"),
"day" => ("d", "time"),
"millisecond" => ("ms", "time"),
"milliliter" | "millilitre" => ("ml", "volume"),
"liter" | "litre" => ("L", "volume"),
"gallon" => ("gal", "volume"),
"percent" => ("%", "percent"),
"degree" => ("\u{b0}", "temperature"),
"celsius" => ("\u{b0}C", "temperature"),
"fahrenheit" => ("\u{b0}F", "temperature"),
"kelvin" => ("K", "temperature"),
"dollar" => ("USD", "currency"),
"euro" => ("EUR", "currency"),
"pound sterling" => ("GBP", "currency"), "yen" => ("JPY", "currency"),
"rupee" => ("INR", "currency"),
"yuan" => ("CNY", "currency"),
"franc" => ("CHF", "currency"),
"ruble" => ("RUB", "currency"),
"won" => ("KRW", "currency"),
_ => return None,
})
}
fn try_word_unit(
text: &str,
lang: &str,
prefer: &HashMap<String, String>,
) -> Result<Option<Quantity>, W2nError> {
let parts = py_rsplit_once_ws(text);
if parts.len() != 2 {
return Ok(None);
}
let head = parts[0];
let raw_tail = parts[1];
let tail = py_rstrip_char(&raw_tail.to_lowercase(), 's').to_string();
let (short, kind) = match word_unit(&tail) {
Some(v) => v,
None => {
match resolve_unit(raw_tail, prefer) {
Some((info, _alt, _conf)) => (info.short, info.kind),
None => return Ok(None),
}
}
};
let value = match call_words2num(head, lang) {
Ok(v) => v,
Err(_) => return Ok(None),
};
if kind == "currency" {
let info = currencies_get(short).expect("every currency short is a CURRENCIES key");
return Ok(Some(Quantity {
value,
unit: Some(info.code.to_string()),
unit_long: Some(info.long.to_string()),
kind: Some("currency".to_string()),
confidence: 1.0,
raw: String::new(),
}));
}
let long_name = UNITS
.iter()
.find(|(_, u)| u.short == short)
.map(|(_, u)| u.long.to_string())
.unwrap_or_else(|| tail.clone());
Ok(Some(Quantity {
value,
unit: Some(short.to_string()),
unit_long: Some(long_name),
kind: Some(kind.to_string()),
confidence: 1.0,
raw: String::new(),
}))
}
pub fn auto_parse(
text: &str,
lang: &str,
prefer: &HashMap<String, String>,
thousands_sep: Option<&str>,
decimal_sep: Option<&str>,
) -> Result<Quantity, W2nError> {
let raw = text;
let text = py_strip(text);
if text.is_empty() {
return Err(W2nError::Words2Num("empty input".to_string()));
}
let chars: Vec<char> = text.chars().collect();
if let Some((sym, num_str, scale)) = try_currency_prefix(&chars) {
match (|| -> Result<Quantity, W2nError> {
let mut value = parse_number_string(&num_str, thousands_sep, decimal_sep, Some(lang))?;
if let Some(s) = scale.as_deref().filter(|s| !s.is_empty()) {
value = apply_scale(value, s)?;
}
Ok(quantity_currency(value, &sym, raw))
})() {
Ok(q) => return Ok(q),
Err(W2nError::Words2Num(_)) => {}
Err(e) => return Err(e),
}
}
if let Some((sym, num_str)) = try_currency_suffix(&chars) {
match parse_number_string(&num_str, thousands_sep, decimal_sep, Some(lang)) {
Ok(value) => return Ok(quantity_currency(value, &sym, raw)),
Err(W2nError::Words2Num(_)) => {}
Err(e) => return Err(e),
}
}
if let Some(mut res) = try_digit_unit(&chars, prefer, thousands_sep, decimal_sep, lang)? {
res.raw = raw.to_string();
return Ok(res);
}
if let Some(mut res) = try_word_unit(text, lang, prefer)? {
res.raw = raw.to_string();
return Ok(res);
}
match parse_number_string(text, thousands_sep, decimal_sep, Some(lang)) {
Ok(value) => return Ok(Quantity::bare(value, raw)),
Err(W2nError::Words2Num(_)) => {}
Err(e) => return Err(e),
}
match call_words2num(text, lang) {
Ok(v) => Ok(Quantity::bare(v, raw)),
Err(e) => Err(W2nError::Words2Num(format!(
"could not auto-parse {}: {}",
py_repr_str(raw),
e.message()
))),
}
}
fn eat_num(cur: &mut Cursor) -> Option<String> {
let start = cur.i;
cur.eat_if(|c| c == '-' || c == '+');
if !cur.peek().is_some_and(is_unicode_digit) {
cur.i = start;
return None;
}
cur.take_while(is_unicode_digit);
loop {
let save = cur.i;
if !cur.eat_if(is_num_sep) {
break;
}
if !cur.peek().is_some_and(is_unicode_digit) {
cur.i = save;
break;
}
cur.take_while(is_unicode_digit);
}
Some(cur.c[start..cur.i].iter().collect())
}
fn match_quantity_at(chars: &[char], pos: usize) -> Option<usize> {
{
let mut cur = Cursor {
c: chars,
i: pos,
};
if cur.peek().is_some_and(is_currency_symbol) {
cur.i += 1;
cur.skip_ws();
if eat_num(&mut cur).is_some() {
let save = cur.i;
cur.skip_ws();
if cur.eat_if(|c| "kKmMbBtT".contains(c)) {
cur.eat_if(|c| c == 'n' || c == 'N');
} else {
cur.i = save;
}
return Some(cur.i);
}
}
}
{
let mut cur = Cursor {
c: chars,
i: pos,
};
if eat_num(&mut cur).is_some() {
let save = cur.i;
cur.skip_ws();
let matched = if cur.peek() == Some('\u{b0}') {
cur.i += 1;
cur.eat_if(|c| c == 'C' || c == 'F');
true
} else if cur.peek() == Some('%') || cur.peek().is_some_and(is_currency_symbol) {
cur.i += 1;
true
} else if (0..3).all(|k| cur.at(k).is_some_and(|c| c.is_ascii_uppercase())) {
cur.i += 3;
true
} else if cur
.peek()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '\u{b5}')
{
cur.take_while(|c| c.is_ascii_alphabetic() || c == '\u{b5}');
true
} else {
false
};
if !matched {
cur.i = save;
}
return Some(cur.i);
}
}
None
}
fn format_number(value: &W2nValue) -> String {
if let W2nValue::Float(f) = value {
if f.is_finite() && f.fract() == 0.0 {
if *f == 0.0 {
return "0".to_string(); }
if let Some(i) = BigInt::from_f64(*f) {
return i.to_string();
}
}
}
value.py_str()
}
fn irregular_plural(long_form: &str) -> Option<&'static str> {
Some(match long_form {
"foot" => "feet",
"inch" => "inches",
"pound sterling" => "pounds sterling",
"degree celsius" => "degrees celsius",
"degree fahrenheit" => "degrees fahrenheit",
"Swiss franc" => "Swiss francs",
"Canadian dollar" => "Canadian dollars",
"Australian dollar" => "Australian dollars",
"Mexican peso" => "Mexican pesos",
"US dollar" => "US dollars",
_ => return None,
})
}
fn is_uncountable(long_form: &str) -> bool {
matches!(long_form, "yen" | "yuan" | "won" | "kelvin" | "percent")
}
pub fn pluralize(long_form: Option<&str>, value: &W2nValue) -> Option<String> {
let long_form = long_form?;
if value.is_unit_magnitude() {
return Some(long_form.to_string());
}
if is_uncountable(long_form) {
return Some(long_form.to_string());
}
if let Some(p) = irregular_plural(long_form) {
return Some(p.to_string());
}
if long_form.ends_with('s')
|| long_form.ends_with('x')
|| long_form.ends_with('z')
|| long_form.ends_with("ch")
|| long_form.ends_with("sh")
{
return Some(format!("{}es", long_form));
}
let chars: Vec<char> = long_form.chars().collect();
if chars.last() == Some(&'y') && chars.len() >= 2 && !"aeiou".contains(chars[chars.len() - 2]) {
let stem: String = chars[..chars.len() - 1].iter().collect();
return Some(format!("{}ies", stem));
}
Some(format!("{}s", long_form))
}
fn format_quantity(q: &Quantity, expand: bool) -> String {
let Some(unit) = q.unit.as_deref() else {
return format_number(&q.value);
};
if expand {
let label = pluralize(q.unit_long.as_deref(), &q.value);
return format!(
"{} {}",
format_number(&q.value),
label.unwrap_or_else(|| "None".to_string())
);
}
if unit == "%" || unit == "\u{b0}" {
return format!("{}{}", format_number(&q.value), unit);
}
format!("{} {}", format_number(&q.value), unit)
}
pub fn auto_parse_sentence(
text: &str,
lang: &str,
prefer: &HashMap<String, String>,
thousands_sep: Option<&str>,
decimal_sep: Option<&str>,
expand: bool,
) -> Result<String, W2nError> {
let chars: Vec<char> = text.chars().collect();
let mut out = String::new();
let mut i = 0usize;
while i < chars.len() {
match match_quantity_at(&chars, i) {
Some(end) if end > i => {
let raw: String = chars[i..end].iter().collect();
match auto_parse(&raw, lang, prefer, thousands_sep, decimal_sep) {
Ok(q) => out.push_str(&format_quantity(&q, expand)),
Err(W2nError::Words2Num(_)) => out.push_str(&raw),
Err(e) => return Err(e),
}
i = end;
}
_ => {
out.push(chars[i]);
i += 1;
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn int(n: i64) -> W2nValue {
W2nValue::Int(BigInt::from(n))
}
#[test]
fn resolve_lang_rules() {
assert_eq!(resolve_lang("en").unwrap(), "en");
assert_eq!(resolve_lang("en-US").unwrap(), "en"); assert_eq!(resolve_lang("zh").unwrap(), "zh");
assert!(resolve_lang("xx").is_err());
}
#[test]
fn converter_en_cardinal_ordinal_year() {
let c = converter_for("en");
assert_eq!(c.to_cardinal("forty-two").unwrap(), int(42));
assert_eq!(c.to_ordinal("twenty-first").unwrap(), int(21));
assert_eq!(c.to_year("nineteen ninety nine").unwrap(), int(1999));
assert!(c.to_cardinal("minus").is_err()); }
#[test]
fn walker_matches_python() {
assert_eq!(
words2num_sentence("I bought twenty-three apples.", "en", "cardinal", false).unwrap(),
"I bought 23 apples."
);
assert_eq!(
words2num_sentence(
"In nineteen ninety nine, two thousand people came.",
"en",
"year",
false
)
.unwrap(),
"In 1999, 2000 people came."
);
assert_eq!(
words2num_sentence("minus forty two", "en", "cardinal", false).unwrap(),
"minus 42"
);
assert_eq!(
words2num_sentence("point five", "en", "cardinal", false).unwrap(),
"0.5"
);
}
#[test]
fn auto_parse_currency_and_units() {
let prefer = HashMap::new();
let q = auto_parse("$12.50", "en", &prefer, None, None).unwrap();
assert_eq!(q.value, W2nValue::Float(12.5));
assert_eq!(q.unit.as_deref(), Some("USD"));
assert_eq!(q.kind.as_deref(), Some("currency"));
let q = auto_parse("forty-two kg", "en", &prefer, None, None).unwrap();
assert_eq!(q.value, int(42));
assert_eq!(q.unit.as_deref(), Some("kg"));
let q = auto_parse("$5bn", "en", &prefer, None, None).unwrap();
assert_eq!(q.value, int(5_000_000_000));
assert_eq!(
auto_parse("$5kn", "en", &prefer, None, None),
Err(W2nError::Key("kn".to_string()))
);
}
#[test]
fn auto_parse_sentence_rewrites() {
let prefer = HashMap::new();
let out = auto_parse_sentence(
"The package weighs 5kg and costs $12.50.",
"en",
&prefer,
None,
None,
false,
)
.unwrap();
assert!(out.contains("5 kg"));
assert!(out.contains("12.5 USD"));
}
#[test]
fn pluralize_rules() {
assert_eq!(pluralize(Some("dollar"), &int(5)).as_deref(), Some("dollars"));
assert_eq!(pluralize(Some("dollar"), &int(1)).as_deref(), Some("dollar"));
assert_eq!(pluralize(Some("foot"), &int(5)).as_deref(), Some("feet"));
assert_eq!(pluralize(Some("yen"), &int(5)).as_deref(), Some("yen"));
}
}