1use bigdecimal::num_traits::FromPrimitive;
45use bigdecimal::BigDecimal;
46use num_bigint::BigInt;
47use std::collections::HashMap;
48use std::str::FromStr;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum W2nError {
59 Words2Num(String),
61 NotImplemented(String),
63 Key(String),
66}
67
68impl W2nError {
69 pub fn message(&self) -> String {
74 match self {
75 W2nError::Words2Num(m) | W2nError::NotImplemented(m) => m.clone(),
76 W2nError::Key(k) => py_repr_str(k),
77 }
78 }
79}
80
81#[derive(Debug, Clone, PartialEq)]
94pub enum W2nValue {
95 Int(BigInt),
96 Float(f64),
97 Dec(BigDecimal),
98}
99
100impl W2nValue {
101 pub fn py_str(&self) -> String {
103 match self {
104 W2nValue::Int(i) => i.to_string(),
105 W2nValue::Float(f) => py_float_repr(*f),
106 W2nValue::Dec(d) => {
107 let (digits, scale) = d.as_bigint_and_exponent();
108 py_decimal_str(&digits, scale)
109 }
110 }
111 }
112
113 pub fn py_repr(&self) -> String {
115 match self {
116 W2nValue::Dec(_) => format!("Decimal('{}')", self.py_str()),
117 _ => self.py_str(),
118 }
119 }
120
121 fn is_unit_magnitude(&self) -> bool {
124 match self {
125 W2nValue::Int(i) => {
126 let one = BigInt::from(1);
127 *i == one || *i == -one
128 }
129 W2nValue::Float(f) => *f == 1.0 || *f == -1.0,
130 W2nValue::Dec(d) => {
131 let one = BigDecimal::from(1);
132 *d == one || *d == -one
133 }
134 }
135 }
136}
137
138fn en_to_sentence_value(v: crate::w2n_lang_en::W2nValue) -> W2nValue {
147 use crate::w2n_lang_en::W2nValue as E;
148 match v {
149 E::Int(i) => W2nValue::Int(i),
150 E::Float(f) => W2nValue::Float(f),
151 E::Dec(d) => W2nValue::Dec(d.to_bigdecimal()),
152 }
153}
154
155pub fn py_repr_str(s: &str) -> String {
169 let quote = if s.contains('\'') && !s.contains('"') {
170 '"'
171 } else {
172 '\''
173 };
174 let mut out = String::with_capacity(s.len() + 2);
175 out.push(quote);
176 for c in s.chars() {
177 match c {
178 '\\' => out.push_str("\\\\"),
179 '\n' => out.push_str("\\n"),
180 '\r' => out.push_str("\\r"),
181 '\t' => out.push_str("\\t"),
182 c if c == quote => {
183 out.push('\\');
184 out.push(c);
185 }
186 c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
187 out.push_str(&format!("\\x{:02x}", c as u32));
188 }
189 c => out.push(c),
190 }
191 }
192 out.push(quote);
193 out
194}
195
196pub fn py_float_repr(v: f64) -> String {
209 if v.is_nan() {
210 return "nan".to_string();
211 }
212 if v.is_infinite() {
213 return if v > 0.0 { "inf" } else { "-inf" }.to_string();
214 }
215 let sci = format!("{:e}", v); let (mant, exp) = sci.split_once('e').expect("{:e} always emits an exponent");
217 let exp: i32 = exp.parse().expect("{:e} exponent is an integer");
218 let neg = mant.starts_with('-');
219 let mant = mant.trim_start_matches('-');
220 let digits: String = mant.chars().filter(|c| *c != '.').collect();
221 let decpt = exp + 1;
222 let sign = if neg { "-" } else { "" };
223 let nd = digits.len() as i32;
224
225 if decpt <= -4 || decpt > 16 {
226 let mut m = String::from(&digits[..1]);
228 if nd > 1 {
229 m.push('.');
230 m.push_str(&digits[1..]);
231 }
232 let e = decpt - 1;
233 let (esign, eabs) = if e < 0 { ("-", -e) } else { ("+", e) };
234 return format!("{}{}e{}{:02}", sign, m, esign, eabs);
235 }
236 if decpt <= 0 {
237 format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
238 } else if decpt >= nd {
239 format!(
241 "{}{}{}.0",
242 sign,
243 digits,
244 "0".repeat((decpt - nd) as usize)
245 )
246 } else {
247 format!(
248 "{}{}.{}",
249 sign,
250 &digits[..decpt as usize],
251 &digits[decpt as usize..]
252 )
253 }
254}
255
256pub fn py_decimal_str(digits: &BigInt, scale: i64) -> String {
270 let neg = digits.sign() == num_bigint::Sign::Minus;
271 let int_str = digits.magnitude().to_string(); let exp: i64 = -scale; let len_int = int_str.chars().count() as i64;
274 let leftdigits = exp + len_int;
275
276 let dotplace = if exp <= 0 && leftdigits > -6 {
277 leftdigits
278 } else {
279 1
280 };
281
282 let (intpart, fracpart) = if dotplace <= 0 {
283 (
284 String::from("0"),
285 format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
286 )
287 } else if dotplace >= len_int {
288 (
289 format!("{}{}", int_str, "0".repeat((dotplace - len_int) as usize)),
290 String::new(),
291 )
292 } else {
293 let cut = dotplace as usize;
294 (int_str[..cut].to_string(), format!(".{}", &int_str[cut..]))
295 };
296
297 let expo = if leftdigits == dotplace {
298 String::new()
299 } else {
300 format!("E{:+}", leftdigits - dotplace)
301 };
302 format!(
303 "{}{}{}{}",
304 if neg { "-" } else { "" },
305 intpart,
306 fracpart,
307 expo
308 )
309}
310
311const ND_BLOCKS: [u32; 65] = [
325 0x0030, 0x0660, 0x06F0, 0x07C0, 0x0966, 0x09E6, 0x0A66, 0x0AE6, 0x0B66, 0x0BE6, 0x0C66, 0x0CE6,
326 0x0D66, 0x0DE6, 0x0E50, 0x0ED0, 0x0F20, 0x1040, 0x1090, 0x17E0, 0x1810, 0x1946, 0x19D0, 0x1A80,
327 0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0,
328 0xFF10, 0x104A0, 0x10D30, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0,
329 0x11650, 0x116C0, 0x11730, 0x118E0, 0x11950, 0x11C50, 0x11D50, 0x11DA0, 0x16A60, 0x16B50,
330 0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6, 0x1E140, 0x1E2F0, 0x1E950, 0x1FBF0,
331];
332
333pub fn digit_value(c: char) -> Option<u32> {
335 let cp = c as u32;
336 match ND_BLOCKS.binary_search(&cp) {
337 Ok(_) => Some(0),
338 Err(0) => None,
339 Err(i) => {
340 let start = ND_BLOCKS[i - 1];
341 if cp - start < 10 {
342 Some(cp - start)
343 } else {
344 None
345 }
346 }
347 }
348}
349
350pub fn is_unicode_digit(c: char) -> bool {
352 digit_value(c).is_some()
353}
354
355fn to_ascii_digits(s: &str) -> String {
358 s.chars()
359 .map(|c| match digit_value(c) {
360 Some(v) if !c.is_ascii_digit() => char::from_digit(v, 10).unwrap_or(c),
361 _ => c,
362 })
363 .collect()
364}
365
366pub fn py_is_space(c: char) -> bool {
370 c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
371}
372
373fn py_str_isspace(s: &str) -> bool {
376 !s.is_empty() && s.chars().all(py_is_space)
377}
378
379fn py_strip(s: &str) -> &str {
381 s.trim_matches(py_is_space)
382}
383
384fn py_split_whitespace(s: &str) -> Vec<&str> {
387 s.split(py_is_space).filter(|p| !p.is_empty()).collect()
388}
389
390fn py_rsplit_once_ws(s: &str) -> Vec<&str> {
396 let chars: Vec<(usize, char)> = s.char_indices().collect();
397 let mut end = chars.len();
398 while end > 0 && py_is_space(chars[end - 1].1) {
399 end -= 1;
400 }
401 if end == 0 {
402 return Vec::new();
403 }
404 let mut tail_start = end;
405 while tail_start > 0 && !py_is_space(chars[tail_start - 1].1) {
406 tail_start -= 1;
407 }
408 let byte_end = if end == chars.len() {
409 s.len()
410 } else {
411 chars[end].0
412 };
413 let tail = &s[chars[tail_start].0..byte_end];
414 let mut head_end = tail_start;
415 while head_end > 0 && py_is_space(chars[head_end - 1].1) {
416 head_end -= 1;
417 }
418 if head_end == 0 {
419 return vec![tail];
420 }
421 vec![&s[..chars[head_end].0], tail]
422}
423
424fn py_rstrip_char(s: &str, ch: char) -> &str {
426 s.trim_end_matches(ch)
427}
428
429fn rstrip_punct(s: &str) -> &str {
431 s.trim_end_matches(['.', ',', ';', ':', '!', '?', '"', '\''])
432}
433
434fn trailing_punct(s: &str) -> &str {
438 let kept = rstrip_punct(s);
439 &s[kept.len()..]
440}
441
442fn ends_with_terminal_punct(s: &str) -> bool {
445 matches!(
446 s.chars().next_back(),
447 Some('.') | Some(',') | Some(';') | Some(':') | Some('!') | Some('?')
448 )
449}
450
451fn tokenize(sentence: &str) -> Vec<String> {
455 let chars: Vec<char> = sentence.chars().collect();
456 let mut parts = Vec::new();
457 let mut i = 0;
458 while i < chars.len() {
459 let space = py_is_space(chars[i]);
460 let start = i;
461 while i < chars.len() && py_is_space(chars[i]) == space {
462 i += 1;
463 }
464 parts.push(chars[start..i].iter().collect());
465 }
466 parts
467}
468
469pub const CONVERTER_LANGS: [&str; 120] = [
480 "af", "am", "ar", "as", "az", "ba", "be", "bg", "bn", "bo", "br", "bs", "ca", "ce", "cs", "cy",
481 "da", "de", "el", "en", "en_IN", "en_NG", "eo", "es", "es_CO", "es_CR", "es_GT", "es_NI",
482 "es_VE", "et", "eu", "fa", "fi", "fo", "fr", "fr_BE", "fr_CH", "fr_DZ", "gl", "gu", "ha", "haw",
483 "he", "hi", "hr", "ht", "hu", "hy", "id", "is", "it", "ja", "jw", "ka", "kk", "km", "kn", "ko",
484 "kz", "la", "lb", "ln", "lo", "lt", "lv", "mg", "mi", "mk", "ml", "mn", "mr", "ms", "mt", "my",
485 "ne", "nl", "nn", "no", "oc", "pa", "pl", "ps", "pt", "pt_BR", "ro", "ru", "sa", "sd", "si",
486 "sk", "sl", "sn", "so", "sq", "sr", "su", "sv", "sw", "ta", "te", "tet", "tg", "th", "tk", "tl",
487 "tr", "tt", "uk", "ur", "uz", "vi", "wo", "yi", "yo", "zh", "zh_CN", "zh_HK", "zh_TW", "jp",
488 "cn",
489];
490
491fn is_known_lang(k: &str) -> bool {
492 CONVERTER_LANGS.contains(&k)
493}
494
495pub fn resolve_lang(lang: &str) -> Result<String, W2nError> {
514 if is_known_lang(lang) {
515 return Ok(lang.to_string());
516 }
517 let normalized = lang.replace('-', "_");
518 if is_known_lang(&normalized) {
519 return Ok(normalized);
520 }
521 let parts: Vec<&str> = normalized.split('_').collect();
523 if parts.len() >= 2 {
524 let candidate = format!("{}_{}", parts[0].to_lowercase(), parts[1].to_uppercase());
525 if is_known_lang(&candidate) {
526 return Ok(candidate);
527 }
528 if is_known_lang(parts[0]) {
529 return Ok(parts[0].to_string());
530 }
531 }
532 let two: String = normalized.chars().take(2).collect();
533 if is_known_lang(&two) {
534 return Ok(two);
535 }
536 Err(W2nError::NotImplemented(format!(
537 "language {} is not supported",
538 py_repr_str(lang)
539 )))
540}
541
542const BASE_NEGATIVE_WORDS: [&str; 2] = ["minus", "negative"];
549
550enum Converter {
552 En,
554 Table(&'static str),
557}
558
559fn converter_for(resolved: &str) -> Converter {
566 match resolved {
567 "en" => Converter::En,
568 "zh" | "cn" => Converter::Table("zh_CN"),
569 "jp" => Converter::Table("ja"),
570 other => {
572 let key = CONVERTER_LANGS
573 .iter()
574 .copied()
575 .find(|k| *k == other)
576 .unwrap_or("en");
577 Converter::Table(key)
578 }
579 }
580}
581
582impl Converter {
583 fn is_number_word(&self, token: &str) -> bool {
585 self.to_cardinal(token).is_ok()
586 }
587
588 fn to_cardinal(&self, text: &str) -> Result<W2nValue, W2nError> {
589 match self {
590 Converter::En => en_convert(crate::en_to_cardinal(text)),
591 Converter::Table(lang) => base_convert(lang, text, false),
592 }
593 }
594
595 fn to_ordinal(&self, text: &str) -> Result<W2nValue, W2nError> {
596 match self {
597 Converter::En => en_convert(crate::en_to_ordinal(text)),
598 Converter::Table(lang) => base_convert(lang, text, true),
599 }
600 }
601
602 fn to_year(&self, text: &str) -> Result<W2nValue, W2nError> {
603 match self {
604 Converter::En => en_convert(crate::en_to_year(text)),
605 Converter::Table(lang) => base_convert(lang, text, false),
607 }
608 }
609
610 fn to_ordinal_num(&self, text: &str) -> Result<W2nValue, W2nError> {
612 base_ordinal_num(text)
613 }
614
615 fn to_currency(&self, text: &str) -> Result<W2nValue, W2nError> {
618 self.to_cardinal(text)
619 }
620
621 fn convert(&self, to: &str, text: &str, has_kwargs: bool) -> Result<W2nValue, W2nError> {
629 if has_kwargs {
630 return Err(W2nError::Words2Num(
632 "unexpected keyword argument".to_string(),
633 ));
634 }
635 match to {
636 "cardinal" => self.to_cardinal(text),
637 "ordinal" => self.to_ordinal(text),
638 "year" => self.to_year(text),
639 "ordinal_num" => self.to_ordinal_num(text),
640 "currency" => self.to_currency(text),
641 _ => Err(W2nError::NotImplemented(format!(
643 "'Words2Num' object has no attribute 'to_{}'",
644 to
645 ))),
646 }
647 }
648}
649
650fn en_convert(
652 r: Result<crate::w2n_lang_en::W2nValue, crate::w2n_lang_en::W2nError>,
653) -> Result<W2nValue, W2nError> {
654 match r {
655 Ok(v) => Ok(en_to_sentence_value(v)),
656 Err(e) => Err(W2nError::Words2Num(e.msg)),
657 }
658}
659
660fn base_convert(lang: &str, text: &str, ordinal: bool) -> Result<W2nValue, W2nError> {
663 if crate::supported_langs().contains(&lang) {
666 let neg = [
667 BASE_NEGATIVE_WORDS[0].to_string(),
668 BASE_NEGATIVE_WORDS[1].to_string(),
669 ];
670 if let Ok(Some(v)) = crate::lookup(lang, text, ordinal, &neg) {
671 return Ok(W2nValue::Int(BigInt::from(v)));
672 }
673 }
674 parse_literal(text)
675}
676
677fn parse_literal(text: &str) -> Result<W2nValue, W2nError> {
682 let normalized = crate::normalize(text);
683 let unparseable = |s: &str| W2nError::Words2Num(format!("cannot parse {} as a number", py_repr_str(s)));
684 if normalized.is_empty() {
685 return Err(unparseable(&normalized));
686 }
687 for neg in BASE_NEGATIVE_WORDS {
688 if let Some(rest) = normalized.strip_prefix(&format!("{} ", neg)) {
689 return finish_parse_literal(rest, -1);
690 }
691 if normalized == neg {
692 return Err(unparseable(&normalized));
693 }
694 }
695 finish_parse_literal(&normalized, 1)
696}
697
698fn finish_parse_literal(normalized: &str, sign: i64) -> Result<W2nValue, W2nError> {
700 if normalized.contains('.') {
701 if let Some(f) = py_float(normalized) {
702 return Ok(W2nValue::Float(if sign < 0 { -f } else { f }));
703 }
704 } else if let Some(i) = py_int(normalized) {
705 return Ok(W2nValue::Int(if sign < 0 { -i } else { i }));
706 }
707 Err(W2nError::Words2Num(format!(
708 "cannot parse {} as a number",
709 py_repr_str(normalized)
710 )))
711}
712
713fn base_ordinal_num(text: &str) -> Result<W2nValue, W2nError> {
721 let chars: Vec<char> = text.chars().collect();
722 for i in 0..chars.len() {
723 if chars[i] == '-' && chars.get(i + 1).copied().is_some_and(is_unicode_digit) {
725 let mut j = i + 1;
726 while j < chars.len() && is_unicode_digit(chars[j]) {
727 j += 1;
728 }
729 let group: String = chars[i..j].iter().collect();
730 return py_int(&group)
731 .map(W2nValue::Int)
732 .ok_or_else(|| W2nError::Words2Num(unparseable_msg(text)));
733 }
734 if is_unicode_digit(chars[i]) {
735 let mut j = i;
736 while j < chars.len() && is_unicode_digit(chars[j]) {
737 j += 1;
738 }
739 let group: String = chars[i..j].iter().collect();
740 return py_int(&group)
741 .map(W2nValue::Int)
742 .ok_or_else(|| W2nError::Words2Num(unparseable_msg(text)));
743 }
744 }
745 Err(W2nError::Words2Num(unparseable_msg(text)))
746}
747
748fn unparseable_msg(text: &str) -> String {
749 format!("cannot parse {} as a number", py_repr_str(text))
750}
751
752fn py_int(s: &str) -> Option<BigInt> {
755 let t = s.trim_matches(py_is_space);
756 let (neg, body) = match t.strip_prefix('-') {
757 Some(r) => (true, r),
758 None => (false, t.strip_prefix('+').unwrap_or(t)),
759 };
760 if body.is_empty() || !body.chars().all(is_unicode_digit) {
761 return None;
762 }
763 let ascii = to_ascii_digits(body);
764 let v = BigInt::from_str(&ascii).ok()?;
765 Some(if neg { -v } else { v })
766}
767
768fn py_float(s: &str) -> Option<f64> {
770 let t = s.trim_matches(py_is_space);
771 let ascii = to_ascii_digits(t);
772 ascii.parse::<f64>().ok()
773}
774
775fn call_words2num(text: &str, lang: &str) -> Result<W2nValue, W2nError> {
778 let resolved = resolve_lang(lang)?;
779 converter_for(&resolved).to_cardinal(text)
780}
781
782pub fn words2num(
801 text: &str,
802 lang: &str,
803 to: &str,
804) -> Result<crate::w2n_lang_en::W2nValue, W2nError> {
805 let resolved = resolve_lang(lang)?;
806 let converter = converter_for(&resolved);
807
808 const CONVERTER_TYPES: [&str; 5] = ["cardinal", "ordinal", "ordinal_num", "year", "currency"];
811 if !CONVERTER_TYPES.contains(&to) {
812 return Err(W2nError::NotImplemented(format!(
813 "conversion type {} unsupported",
814 py_repr_str(to)
815 )));
816 }
817
818 match &converter {
822 Converter::En => match to {
823 "cardinal" | "currency" => {
825 crate::en_to_cardinal(text).map_err(|e| W2nError::Words2Num(e.msg))
826 }
827 "ordinal" => crate::en_to_ordinal(text).map_err(|e| W2nError::Words2Num(e.msg)),
828 "year" => crate::en_to_year(text).map_err(|e| W2nError::Words2Num(e.msg)),
829 _ => base_ordinal_num(text).map(sentence_to_en_value),
831 },
832 Converter::Table(_) => {
833 let v = match to {
834 "cardinal" | "year" | "currency" => converter.to_cardinal(text),
836 "ordinal" => converter.to_ordinal(text),
837 _ => converter.to_ordinal_num(text),
838 }?;
839 Ok(sentence_to_en_value(v))
840 }
841 }
842}
843
844fn sentence_to_en_value(v: W2nValue) -> crate::w2n_lang_en::W2nValue {
849 use crate::w2n_lang_en::W2nValue as EnV;
850 match v {
851 W2nValue::Int(i) => EnV::Int(i),
852 W2nValue::Float(f) => EnV::Float(f),
853 W2nValue::Dec(d) => EnV::Dec(crate::w2n_lang_en::PyDec::from_bigdecimal(&d)),
854 }
855}
856
857fn starts_run(converter: &Converter, token: &str) -> bool {
864 if token.is_empty() {
865 return false;
866 }
867 let dehyphened = token.replace('-', " ");
868 py_split_whitespace(&dehyphened)
869 .iter()
870 .any(|sub| converter.is_number_word(sub))
871}
872
873fn is_candidate(converter: &Converter, token: &str, includable: &[&str]) -> bool {
875 if token.is_empty() {
876 return false;
877 }
878 if includable.contains(&token) {
879 return true;
880 }
881 let dehyphened = token.replace('-', " ");
882 py_split_whitespace(&dehyphened)
883 .iter()
884 .any(|sub| converter.is_number_word(sub))
885}
886
887const INCLUDABLE_EN: [&str; 7] = ["and", "point", "dot", "minus", "negative", "a", "an"];
892
893pub fn words2num_sentence(
903 sentence: &str,
904 lang: &str,
905 to: &str,
906 has_kwargs: bool,
907) -> Result<String, W2nError> {
908 let resolved = resolve_lang(lang)?;
909 let converter = converter_for(&resolved);
910 let includable: &[&str] = if resolved == "en" { &INCLUDABLE_EN } else { &[] };
911
912 let parts = tokenize(sentence);
913 let n = parts.len();
914 let mut out = String::new();
915 let mut i = 0usize;
916
917 while i < n {
918 let piece = &parts[i];
919 if py_str_isspace(piece) {
920 out.push_str(piece);
921 i += 1;
922 continue;
923 }
924 let head = rstrip_punct(piece).to_lowercase();
926 if !starts_run(&converter, &head) {
927 out.push_str(piece);
928 i += 1;
929 continue;
930 }
931
932 let mut best_value: Option<W2nValue> = None;
934 let mut best_end = i;
935 let mut j = i;
936 while j < n {
937 let tok = &parts[j];
938 if py_str_isspace(tok) {
939 j += 1;
940 continue;
941 }
942 let clean = rstrip_punct(tok).to_lowercase();
943 if !is_candidate(&converter, &clean, includable) {
944 break;
945 }
946 let run = parts[i..=j].concat();
947 let stripped = rstrip_punct(py_strip(&run)).to_string();
948 if let Ok(v) = converter.convert(to, &stripped, has_kwargs) {
953 best_value = Some(v);
954 best_end = j;
955 }
956 if ends_with_terminal_punct(tok) {
958 break;
959 }
960 j += 1;
961 }
962
963 if let Some(v) = best_value {
964 let run = parts[i..=best_end].concat();
966 let trailing = trailing_punct(&run);
967 out.push_str(&v.py_str());
968 out.push_str(trailing);
969 i = best_end + 1;
970 } else {
971 out.push_str(piece);
972 i += 1;
973 }
974 }
975 Ok(out)
976}
977
978pub fn convert_sentence(
980 sentence: &str,
981 lang: &str,
982 to: &str,
983 has_kwargs: bool,
984) -> Result<String, W2nError> {
985 words2num_sentence(sentence, lang, to, has_kwargs)
986}
987
988pub fn sentence_to_words(
990 sentence: &str,
991 lang: &str,
992 to: &str,
993 has_kwargs: bool,
994) -> Result<String, W2nError> {
995 words2num_sentence(sentence, lang, to, has_kwargs)
996}
997
998#[derive(Debug, Clone, PartialEq)]
1004pub struct UnitInfo {
1005 pub short: &'static str,
1006 pub long: &'static str,
1007 pub kind: &'static str,
1008 pub confidence: f64,
1009}
1010
1011const fn ui(short: &'static str, long: &'static str, kind: &'static str, confidence: f64) -> UnitInfo {
1012 UnitInfo {
1013 short,
1014 long,
1015 kind,
1016 confidence,
1017 }
1018}
1019
1020pub static UNITS: &[(&str, UnitInfo)] = &[
1030 ("mm", ui("mm", "millimeter", "length", 1.0)),
1032 ("cm", ui("cm", "centimeter", "length", 1.0)),
1033 ("dm", ui("dm", "decimeter", "length", 1.0)),
1034 ("m", ui("m", "meter", "length", 0.6)),
1035 ("km", ui("km", "kilometer", "length", 1.0)),
1036 ("in", ui("in", "inch", "length", 0.5)),
1037 ("ft", ui("ft", "foot", "length", 1.0)),
1038 ("yd", ui("yd", "yard", "length", 1.0)),
1039 ("mi", ui("mi", "mile", "length", 1.0)),
1040 ("nm", ui("nm", "nanometer", "length", 1.0)),
1041 ("\u{b5}m", ui("\u{b5}m", "micrometer", "length", 1.0)),
1042 ("um", ui("\u{b5}m", "micrometer", "length", 1.0)),
1043 ("mg", ui("mg", "milligram", "mass", 1.0)),
1045 ("g", ui("g", "gram", "mass", 0.6)),
1046 ("kg", ui("kg", "kilogram", "mass", 1.0)),
1047 ("t", ui("t", "tonne", "mass", 0.5)),
1048 ("lb", ui("lb", "pound", "mass", 1.0)),
1049 ("lbs", ui("lb", "pound", "mass", 1.0)),
1050 ("oz", ui("oz", "ounce", "mass", 1.0)),
1051 ("\u{b0}", ui("\u{b0}", "degree", "temperature", 0.7)),
1053 ("\u{b0}C", ui("\u{b0}C", "degree celsius", "temperature", 1.0)),
1054 ("\u{b0}F", ui("\u{b0}F", "degree fahrenheit", "temperature", 1.0)),
1055 ("K", ui("K", "kelvin", "temperature", 0.6)),
1056 ("C", ui("\u{b0}C", "degree celsius", "temperature", 0.5)),
1057 ("F", ui("\u{b0}F", "degree fahrenheit", "temperature", 0.5)),
1058 ("ms", ui("ms", "millisecond", "time", 1.0)),
1060 ("s", ui("s", "second", "time", 0.6)),
1061 ("sec", ui("s", "second", "time", 1.0)),
1062 ("min", ui("min", "minute", "time", 1.0)),
1063 ("h", ui("h", "hour", "time", 0.7)),
1064 ("hr", ui("h", "hour", "time", 1.0)),
1065 ("hrs", ui("h", "hour", "time", 1.0)),
1066 ("d", ui("d", "day", "time", 0.5)),
1067 ("ml", ui("ml", "milliliter", "volume", 1.0)),
1069 ("cl", ui("cl", "centiliter", "volume", 1.0)),
1070 ("dl", ui("dl", "deciliter", "volume", 1.0)),
1071 ("l", ui("L", "liter", "volume", 0.7)),
1072 ("L", ui("L", "liter", "volume", 1.0)),
1073 ("gal", ui("gal", "gallon", "volume", 1.0)),
1074 ("%", ui("%", "percent", "percent", 1.0)),
1076];
1077
1078pub fn units_get(key: &str) -> Option<&'static UnitInfo> {
1080 UNITS.iter().find(|(k, _)| *k == key).map(|(_, v)| v)
1081}
1082
1083#[derive(Debug, Clone, PartialEq)]
1085pub struct CurrencyInfo {
1086 pub code: &'static str,
1087 pub symbol: &'static str,
1088 pub long: &'static str,
1089}
1090
1091const fn ci(code: &'static str, symbol: &'static str, long: &'static str) -> CurrencyInfo {
1092 CurrencyInfo { code, symbol, long }
1093}
1094
1095pub static CURRENCIES: &[(&str, CurrencyInfo)] = &[
1102 ("$", ci("USD", "$", "dollar")),
1103 ("\u{20ac}", ci("EUR", "\u{20ac}", "euro")),
1104 ("\u{a3}", ci("GBP", "\u{a3}", "pound")),
1105 ("\u{a5}", ci("JPY", "\u{a5}", "yen")),
1106 ("\u{20b9}", ci("INR", "\u{20b9}", "rupee")),
1107 ("\u{20bd}", ci("RUB", "\u{20bd}", "ruble")),
1108 ("\u{20a9}", ci("KRW", "\u{20a9}", "won")),
1109 ("\u{20ba}", ci("TRY", "\u{20ba}", "lira")),
1110 ("USD", ci("USD", "$", "US dollar")),
1111 ("EUR", ci("EUR", "\u{20ac}", "euro")),
1112 ("GBP", ci("GBP", "\u{a3}", "pound sterling")),
1113 ("JPY", ci("JPY", "\u{a5}", "yen")),
1114 ("CHF", ci("CHF", "CHF", "Swiss franc")),
1115 ("CAD", ci("CAD", "$", "Canadian dollar")),
1116 ("AUD", ci("AUD", "$", "Australian dollar")),
1117 ("CNY", ci("CNY", "\u{a5}", "yuan")),
1118 ("INR", ci("INR", "\u{20b9}", "rupee")),
1119 ("BRL", ci("BRL", "R$", "real")),
1120 ("MXN", ci("MXN", "$", "Mexican peso")),
1121 ("RUB", ci("RUB", "\u{20bd}", "ruble")),
1122 ("KRW", ci("KRW", "\u{20a9}", "won")),
1123];
1124
1125pub fn currencies_get(key: &str) -> Option<&'static CurrencyInfo> {
1127 CURRENCIES.iter().find(|(k, _)| *k == key).map(|(_, v)| v)
1128}
1129
1130const CURRENCY_SYMBOLS: [char; 8] = [
1132 '$',
1133 '\u{20ac}',
1134 '\u{a3}',
1135 '\u{a5}',
1136 '\u{20b9}',
1137 '\u{20bd}',
1138 '\u{20a9}',
1139 '\u{20ba}',
1140];
1141
1142fn is_currency_symbol(c: char) -> bool {
1143 CURRENCY_SYMBOLS.contains(&c)
1144}
1145
1146fn scale_suffix(s: &str) -> Option<BigInt> {
1150 let pow = |n: u32| BigInt::from(10u8).pow(n);
1151 match s {
1152 "k" | "K" => Some(pow(3)),
1153 "m" | "M" => Some(pow(6)),
1154 "b" | "B" | "bn" => Some(pow(9)),
1155 "t" | "T" | "tn" => Some(pow(12)),
1156 _ => None,
1157 }
1158}
1159
1160#[derive(Debug, Clone, PartialEq)]
1166pub struct Quantity {
1167 pub value: W2nValue,
1168 pub unit: Option<String>,
1169 pub unit_long: Option<String>,
1170 pub kind: Option<String>,
1171 pub confidence: f64,
1172 pub raw: String,
1173}
1174
1175impl Quantity {
1176 fn bare(value: W2nValue, raw: &str) -> Quantity {
1177 Quantity {
1178 value,
1179 unit: None,
1180 unit_long: None,
1181 kind: None,
1182 confidence: 1.0,
1183 raw: raw.to_string(),
1184 }
1185 }
1186
1187 pub fn py_repr(&self) -> String {
1198 match &self.unit {
1199 Some(u) if !u.is_empty() => format!(
1200 "Quantity(value={}, unit={}, kind={}, confidence={})",
1201 self.value.py_repr(),
1202 py_repr_str(u),
1203 match &self.kind {
1204 Some(k) => py_repr_str(k),
1205 None => "None".to_string(),
1206 },
1207 py_float_repr(self.confidence),
1208 ),
1209 _ => format!("Quantity(value={})", self.value.py_repr()),
1210 }
1211 }
1212}
1213
1214const NUMBER_FORMAT_DEFAULTS: &[(&str, &str, &str)] = &[
1225 ("_default", ",", "."),
1226 ("en", ",", "."),
1227 ("en_GB", ",", "."),
1228 ("en_IN", ",", "."),
1229 ("en_NG", ",", "."),
1230 ("zh", ",", "."),
1231 ("zh_CN", ",", "."),
1232 ("zh_HK", ",", "."),
1233 ("zh_TW", ",", "."),
1234 ("ja", ",", "."),
1235 ("ko", ",", "."),
1236 ("th", ",", "."),
1237 ("vi", ".", ","),
1238 ("fr", " ", ","),
1239 ("fr_BE", " ", ","),
1240 ("fr_DZ", " ", ","),
1241 ("fr_CH", "'", "."),
1242 ("de", ".", ","),
1243 ("es", ".", ","),
1244 ("es_CO", ".", ","),
1245 ("es_CR", ".", ","),
1246 ("es_GT", ".", ","),
1247 ("es_NI", ".", ","),
1248 ("es_VE", ".", ","),
1249 ("it", ".", ","),
1250 ("pt", ".", ","),
1251 ("pt_BR", ".", ","),
1252 ("nl", ".", ","),
1253 ("ro", ".", ","),
1254 ("hr", ".", ","),
1255 ("sl", ".", ","),
1256 ("sr", ".", ","),
1257 ("tr", ".", ","),
1258 ("el", ".", ","),
1259 ("ru", " ", ","),
1260 ("uk", " ", ","),
1261 ("be", " ", ","),
1262 ("bg", " ", ","),
1263 ("pl", " ", ","),
1264 ("cs", " ", ","),
1265 ("sk", " ", ","),
1266 ("hu", " ", ","),
1267 ("sv", " ", ","),
1268 ("no", " ", ","),
1269 ("nn", " ", ","),
1270 ("da", ".", ","),
1271 ("fi", " ", ","),
1272 ("et", " ", ","),
1273 ("lt", " ", ","),
1274 ("lv", " ", ","),
1275 ("is", ".", ","),
1276 ("fo", ".", ","),
1277 ("ar", ",", "."),
1278 ("fa", ",", "."),
1279 ("he", ",", "."),
1280];
1281
1282fn get_format(lang: &str) -> (&'static str, &'static str) {
1284 let find = |k: &str| {
1285 NUMBER_FORMAT_DEFAULTS
1286 .iter()
1287 .find(|(n, _, _)| *n == k)
1288 .map(|(_, t, d)| (*t, *d))
1289 };
1290 if let Some(f) = find(lang) {
1291 return f;
1292 }
1293 let base = lang.split('_').next().unwrap_or("");
1295 if !lang.is_empty() {
1296 if let Some(f) = find(base) {
1297 return f;
1298 }
1299 }
1300 find("_default").expect("_default is always present")
1301}
1302
1303fn to_number(s: &str) -> Result<W2nValue, W2nError> {
1305 let ok = {
1307 let mut it = s.chars().peekable();
1308 let mut n_int = 0usize;
1309 while it.peek().copied().is_some_and(is_unicode_digit) {
1310 it.next();
1311 n_int += 1;
1312 }
1313 let mut good = n_int > 0;
1314 if good && it.peek() == Some(&'.') {
1315 it.next();
1316 let mut n_frac = 0usize;
1317 while it.peek().copied().is_some_and(is_unicode_digit) {
1318 it.next();
1319 n_frac += 1;
1320 }
1321 good = n_frac > 0;
1322 }
1323 good && it.next().is_none()
1324 };
1325 if !ok {
1326 return Err(W2nError::Words2Num(format!(
1327 "not a parseable number: {}",
1328 py_repr_str(s)
1329 )));
1330 }
1331 let ascii = to_ascii_digits(s);
1333 if ascii.contains('.') {
1334 Ok(W2nValue::Float(ascii.parse::<f64>().map_err(|e| {
1335 W2nError::Words2Num(e.to_string())
1336 })?))
1337 } else {
1338 Ok(W2nValue::Int(BigInt::from_str(&ascii).map_err(|e| {
1339 W2nError::Words2Num(e.to_string())
1340 })?))
1341 }
1342}
1343
1344fn strip_space_like(s: &str) -> String {
1347 s.chars()
1348 .filter(|c| !(py_is_space(*c) || matches!(c, '\u{a0}' | '\u{202f}' | '\u{2009}')))
1349 .collect()
1350}
1351
1352fn parse_with_explicit(
1354 s: &str,
1355 thousands_sep: Option<&str>,
1356 decimal_sep: Option<&str>,
1357) -> Result<W2nValue, W2nError> {
1358 let mut s = s.to_string();
1359 if let Some(t) = thousands_sep.filter(|t| !t.is_empty()) {
1361 if matches!(t, " " | "\u{a0}" | "\u{202f}" | "\u{2009}") {
1362 s = strip_space_like(&s);
1363 } else {
1364 s = s.replace(t, "");
1365 }
1366 }
1367 if let Some(d) = decimal_sep.filter(|d| !d.is_empty()) {
1368 if d != "." {
1369 if s.contains('.') {
1370 s = s.replace('.', "");
1374 }
1375 s = s.replace(d, ".");
1376 }
1377 }
1378 to_number(&s)
1379}
1380
1381fn resolve_single_sep(s: &str, sep: char) -> String {
1383 let parts: Vec<&str> = s.split(sep).collect();
1384 if parts.len() > 2 {
1385 return s.replace(sep, "");
1386 }
1387 let after = parts[1]; let n_after = after.chars().count();
1389 if n_after == 3 && !parts[0].is_empty() && parts[0].chars().count() <= 3 {
1390 if sep == '.' {
1391 return s.to_string();
1392 }
1393 return s.replace(',', ".");
1394 }
1395 if n_after != 3 {
1396 return if sep == '.' {
1397 s.to_string()
1398 } else {
1399 s.replace(',', ".")
1400 };
1401 }
1402 s.replace(sep, "")
1403}
1404
1405fn auto_detect_parse(s: &str) -> Result<W2nValue, W2nError> {
1407 let s2: String = s
1409 .chars()
1410 .filter(|c| {
1411 !(py_is_space(*c) || matches!(c, '\u{a0}' | '\u{202f}' | '\u{2009}' | '\'' | '_'))
1412 })
1413 .collect();
1414 let has_comma = s2.contains(',');
1415 let has_dot = s2.contains('.');
1416
1417 if has_comma && has_dot {
1418 let last_comma = s2.rfind(',');
1420 let last_dot = s2.rfind('.');
1421 let s3 = if last_comma > last_dot {
1422 s2.replace('.', "").replace(',', ".")
1423 } else {
1424 s2.replace(',', "")
1425 };
1426 return to_number(&s3);
1427 }
1428 if has_comma {
1429 return to_number(&resolve_single_sep(&s2, ','));
1430 }
1431 if has_dot {
1432 return to_number(&resolve_single_sep(&s2, '.'));
1433 }
1434 to_number(&s2)
1435}
1436
1437fn apply_sign(sign: i32, v: W2nValue) -> W2nValue {
1440 if sign == 1 {
1441 return v;
1442 }
1443 match v {
1444 W2nValue::Int(i) => W2nValue::Int(-i),
1445 W2nValue::Float(f) => W2nValue::Float(-f),
1446 W2nValue::Dec(d) => W2nValue::Dec(-d),
1447 }
1448}
1449
1450fn parse_number_string(
1452 s: &str,
1453 thousands_sep: Option<&str>,
1454 decimal_sep: Option<&str>,
1455 lang: Option<&str>,
1456) -> Result<W2nValue, W2nError> {
1457 let s = py_strip(s);
1458 if s.is_empty() {
1459 return Err(W2nError::Words2Num("empty numeric string".to_string()));
1460 }
1461 let mut sign = 1i32;
1462 let mut rest = s;
1463 let first = s.chars().next().unwrap();
1464 if first == '+' || first == '-' {
1465 if first == '-' {
1466 sign = -1;
1467 }
1468 rest = py_strip_start(&s[first.len_utf8()..]);
1469 }
1470 if rest.is_empty() {
1471 return Err(W2nError::Words2Num(
1472 "empty numeric string after sign".to_string(),
1473 ));
1474 }
1475 if thousands_sep.is_some() || decimal_sep.is_some() {
1476 return Ok(apply_sign(
1477 sign,
1478 parse_with_explicit(rest, thousands_sep, decimal_sep)?,
1479 ));
1480 }
1481 if let Some(l) = lang {
1482 let (t, d) = get_format(l);
1483 match parse_with_explicit(rest, Some(t), Some(d)) {
1484 Ok(v) => return Ok(apply_sign(sign, v)),
1485 Err(W2nError::Words2Num(_)) => {} Err(e) => return Err(e),
1487 }
1488 }
1489 Ok(apply_sign(sign, auto_detect_parse(rest)?))
1490}
1491
1492fn py_strip_start(s: &str) -> &str {
1494 s.trim_start_matches(py_is_space)
1495}
1496
1497fn is_num_class(c: char) -> bool {
1503 is_unicode_digit(c)
1504 || matches!(
1505 c,
1506 '.' | ',' | '\'' | '\u{a0}' | '\u{202f}' | '\u{2009}' | ' ' | '_'
1507 )
1508}
1509
1510fn is_num_sep(c: char) -> bool {
1513 matches!(
1514 c,
1515 '.' | ',' | '\'' | '_' | '\u{a0}' | '\u{202f}' | '\u{2009}' | ' '
1516 )
1517}
1518
1519struct Cursor<'a> {
1522 c: &'a [char],
1523 i: usize,
1524}
1525
1526impl<'a> Cursor<'a> {
1527 fn new(c: &'a [char]) -> Self {
1528 Cursor { c, i: 0 }
1529 }
1530 fn peek(&self) -> Option<char> {
1531 self.c.get(self.i).copied()
1532 }
1533 fn at(&self, k: usize) -> Option<char> {
1534 self.c.get(self.i + k).copied()
1535 }
1536 fn eat_if(&mut self, f: impl Fn(char) -> bool) -> bool {
1537 if self.peek().is_some_and(&f) {
1538 self.i += 1;
1539 true
1540 } else {
1541 false
1542 }
1543 }
1544 fn skip_ws(&mut self) {
1546 while self.eat_if(py_is_space) {}
1547 }
1548 fn take_while(&mut self, f: impl Fn(char) -> bool) -> String {
1549 let start = self.i;
1550 while self.peek().is_some_and(&f) {
1551 self.i += 1;
1552 }
1553 self.c[start..self.i].iter().collect()
1554 }
1555 fn at_end(&self) -> bool {
1556 self.i >= self.c.len()
1557 }
1558}
1559
1560fn eat_signed_num_class(cur: &mut Cursor) -> Option<String> {
1567 let start = cur.i;
1568 cur.eat_if(|c| c == '-' || c == '+');
1569 let body = cur.take_while(is_num_class);
1570 if body.is_empty() {
1571 cur.i = start;
1572 return None;
1573 }
1574 Some(cur.c[start..cur.i].iter().collect())
1575}
1576
1577fn eat_iso_code(cur: &mut Cursor) -> Option<String> {
1579 let mut out = String::new();
1580 for k in 0..3 {
1581 match cur.at(k) {
1582 Some(c) if c.is_ascii_uppercase() => out.push(c),
1583 _ => return None,
1584 }
1585 }
1586 cur.i += 3;
1587 Some(out)
1588}
1589
1590fn try_currency_prefix(text: &[char]) -> Option<(String, String, Option<String>)> {
1600 {
1602 let mut cur = Cursor::new(text);
1603 cur.skip_ws();
1604 if let Some(sym) = cur.peek().filter(|c| is_currency_symbol(*c)) {
1605 cur.i += 1;
1606 cur.skip_ws();
1607 if let Some(num) = eat_signed_num_class(&mut cur) {
1608 cur.skip_ws();
1609 let mut scale = None;
1610 if cur.peek().is_some_and(|c| "kKmMbBtT".contains(c)) {
1611 let mut s = String::new();
1612 s.push(cur.peek().unwrap());
1613 cur.i += 1;
1614 if cur.peek().is_some_and(|c| c == 'n' || c == 'N') {
1615 s.push(cur.peek().unwrap());
1616 cur.i += 1;
1617 }
1618 scale = Some(s);
1619 }
1620 cur.skip_ws();
1621 if cur.at_end() {
1622 return Some((sym.to_string(), py_strip(&num).to_string(), scale));
1623 }
1624 }
1625 }
1626 }
1627 {
1629 let mut cur = Cursor::new(text);
1630 cur.skip_ws();
1631 if let Some(code) = eat_iso_code(&mut cur) {
1632 cur.skip_ws();
1633 if let Some(num) = eat_signed_num_class(&mut cur) {
1634 cur.skip_ws();
1635 if cur.at_end() && currencies_get(&code).is_some() {
1636 return Some((code, py_strip(&num).to_string(), None));
1637 }
1638 }
1639 }
1640 }
1641 None
1642}
1643
1644fn try_currency_suffix(text: &[char]) -> Option<(String, String)> {
1646 {
1648 let mut cur = Cursor::new(text);
1649 cur.skip_ws();
1650 if let Some(num) = eat_signed_num_class(&mut cur) {
1651 cur.skip_ws();
1652 if let Some(sym) = cur.peek().filter(|c| is_currency_symbol(*c)) {
1653 cur.i += 1;
1654 cur.skip_ws();
1655 if cur.at_end() {
1656 return Some((sym.to_string(), py_strip(&num).to_string()));
1657 }
1658 }
1659 }
1660 }
1661 {
1663 let mut cur = Cursor::new(text);
1664 cur.skip_ws();
1665 if let Some(num) = eat_signed_num_class(&mut cur) {
1666 cur.skip_ws();
1667 if let Some(code) = eat_iso_code(&mut cur) {
1668 cur.skip_ws();
1669 if cur.at_end() && currencies_get(&code).is_some() {
1670 return Some((code, py_strip(&num).to_string()));
1671 }
1672 }
1673 }
1674 }
1675 None
1676}
1677
1678fn quantity_currency(value: W2nValue, sym: &str, raw: &str) -> Quantity {
1680 let info = currencies_get(sym).expect("every symbol the regex matches is a CURRENCIES key");
1681 Quantity {
1682 value,
1683 unit: Some(info.code.to_string()),
1684 unit_long: Some(info.long.to_string()),
1685 kind: Some("currency".to_string()),
1686 confidence: 1.0,
1687 raw: raw.to_string(),
1688 }
1689}
1690
1691fn apply_scale(value: W2nValue, scale_str: &str) -> Result<W2nValue, W2nError> {
1699 let multiplier =
1700 scale_suffix(scale_str).ok_or_else(|| W2nError::Key(scale_str.to_string()))?;
1701 match value {
1702 W2nValue::Int(i) => Ok(W2nValue::Int(i * multiplier)),
1703 W2nValue::Float(f) => Ok(W2nValue::Float(f * bigint_to_f64(&multiplier))),
1705 W2nValue::Dec(d) => Ok(W2nValue::Float(
1706 bigdecimal_to_f64(&d) * bigint_to_f64(&multiplier),
1707 )),
1708 }
1709}
1710
1711fn bigint_to_f64(i: &BigInt) -> f64 {
1712 i.to_string().parse::<f64>().unwrap_or(f64::INFINITY)
1713}
1714
1715fn bigdecimal_to_f64(d: &BigDecimal) -> f64 {
1716 d.to_string().parse::<f64>().unwrap_or(f64::INFINITY)
1717}
1718
1719fn resolve_unit(
1724 unit_str: &str,
1725 prefer: &HashMap<String, String>,
1726) -> Option<(&'static UnitInfo, Option<&'static str>, f64)> {
1727 let info = units_get(unit_str).or_else(|| units_get(&unit_str.to_lowercase()))?;
1729
1730 if let Some(target) = prefer.get(unit_str).filter(|t| !t.is_empty()) {
1732 for (_, u) in UNITS.iter() {
1733 if u.long == target || u.short == target {
1734 return Some((info, Some(u.long), info.confidence.max(0.9)));
1737 }
1738 }
1739 }
1740 Some((info, None, info.confidence))
1741}
1742
1743fn try_digit_unit(
1751 text: &[char],
1752 prefer: &HashMap<String, String>,
1753 thousands_sep: Option<&str>,
1754 decimal_sep: Option<&str>,
1755 lang: &str,
1756) -> Result<Option<Quantity>, W2nError> {
1757 let mut cur = Cursor::new(text);
1758 cur.skip_ws();
1759 let Some(num) = eat_signed_num_class(&mut cur) else {
1760 return Ok(None);
1761 };
1762 cur.skip_ws();
1763
1764 let unit: String = if cur.peek() == Some('\u{b0}') {
1766 let mut s = String::from('\u{b0}');
1767 cur.i += 1;
1768 if let Some(c) = cur.peek().filter(|c| *c == 'C' || *c == 'F') {
1769 s.push(c);
1770 cur.i += 1;
1771 }
1772 s
1773 } else if cur.peek() == Some('\u{b5}') && cur.at(1) == Some('m') {
1774 cur.i += 2;
1775 String::from("\u{b5}m")
1776 } else if cur.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
1777 cur.take_while(|c| c.is_ascii_alphabetic())
1778 } else if cur.peek() == Some('%') {
1779 cur.i += 1;
1780 String::from("%")
1781 } else {
1782 return Ok(None);
1783 };
1784 cur.skip_ws();
1785 if !cur.at_end() {
1786 return Ok(None);
1787 }
1788
1789 let num_str = py_strip(&num).to_string();
1790 let unit_str = py_strip(&unit).to_string();
1791
1792 let Some((info, alt_long, conf)) = resolve_unit(&unit_str, prefer) else {
1793 return Ok(None);
1794 };
1795 let value = match parse_number_string(&num_str, thousands_sep, decimal_sep, Some(lang)) {
1796 Ok(v) => v,
1797 Err(W2nError::Words2Num(_)) => return Ok(None),
1798 Err(e) => return Err(e),
1799 };
1800 Ok(Some(Quantity {
1801 value,
1802 unit: Some(info.short.to_string()),
1803 unit_long: Some(alt_long.unwrap_or(info.long).to_string()),
1804 kind: Some(info.kind.to_string()),
1805 confidence: conf,
1806 raw: String::new(),
1807 }))
1808}
1809
1810fn word_unit(tail: &str) -> Option<(&'static str, &'static str)> {
1816 Some(match tail {
1817 "millimeter" | "millimetre" => ("mm", "length"),
1818 "centimeter" | "centimetre" => ("cm", "length"),
1819 "meter" | "metre" => ("m", "length"),
1820 "kilometer" | "kilometre" => ("km", "length"),
1821 "inch" => ("in", "length"),
1822 "foot" | "feet" => ("ft", "length"),
1823 "yard" => ("yd", "length"),
1824 "mile" => ("mi", "length"),
1825 "milligram" => ("mg", "mass"),
1826 "gram" => ("g", "mass"),
1827 "kilogram" => ("kg", "mass"),
1828 "tonne" | "ton" => ("t", "mass"),
1829 "pound" => ("lb", "mass"),
1830 "ounce" => ("oz", "mass"),
1831 "second" => ("s", "time"),
1832 "minute" => ("min", "time"),
1833 "hour" => ("h", "time"),
1834 "day" => ("d", "time"),
1835 "millisecond" => ("ms", "time"),
1836 "milliliter" | "millilitre" => ("ml", "volume"),
1837 "liter" | "litre" => ("L", "volume"),
1838 "gallon" => ("gal", "volume"),
1839 "percent" => ("%", "percent"),
1840 "degree" => ("\u{b0}", "temperature"),
1841 "celsius" => ("\u{b0}C", "temperature"),
1842 "fahrenheit" => ("\u{b0}F", "temperature"),
1843 "kelvin" => ("K", "temperature"),
1844 "dollar" => ("USD", "currency"),
1846 "euro" => ("EUR", "currency"),
1847 "pound sterling" => ("GBP", "currency"), "yen" => ("JPY", "currency"),
1849 "rupee" => ("INR", "currency"),
1850 "yuan" => ("CNY", "currency"),
1851 "franc" => ("CHF", "currency"),
1852 "ruble" => ("RUB", "currency"),
1853 "won" => ("KRW", "currency"),
1854 _ => return None,
1855 })
1856}
1857
1858fn try_word_unit(
1860 text: &str,
1861 lang: &str,
1862 prefer: &HashMap<String, String>,
1863) -> Result<Option<Quantity>, W2nError> {
1864 let parts = py_rsplit_once_ws(text);
1865 if parts.len() != 2 {
1866 return Ok(None);
1867 }
1868 let head = parts[0];
1869 let raw_tail = parts[1];
1870 let tail = py_rstrip_char(&raw_tail.to_lowercase(), 's').to_string();
1872
1873 let (short, kind) = match word_unit(&tail) {
1874 Some(v) => v,
1875 None => {
1876 match resolve_unit(raw_tail, prefer) {
1881 Some((info, _alt, _conf)) => (info.short, info.kind),
1882 None => return Ok(None),
1883 }
1884 }
1885 };
1886
1887 let value = match call_words2num(head, lang) {
1890 Ok(v) => v,
1891 Err(_) => return Ok(None),
1892 };
1893
1894 if kind == "currency" {
1895 let info = currencies_get(short).expect("every currency short is a CURRENCIES key");
1896 return Ok(Some(Quantity {
1897 value,
1898 unit: Some(info.code.to_string()),
1899 unit_long: Some(info.long.to_string()),
1900 kind: Some("currency".to_string()),
1901 confidence: 1.0,
1902 raw: String::new(),
1903 }));
1904 }
1905 let long_name = UNITS
1908 .iter()
1909 .find(|(_, u)| u.short == short)
1910 .map(|(_, u)| u.long.to_string())
1911 .unwrap_or_else(|| tail.clone());
1912 Ok(Some(Quantity {
1913 value,
1914 unit: Some(short.to_string()),
1915 unit_long: Some(long_name),
1916 kind: Some(kind.to_string()),
1917 confidence: 1.0,
1918 raw: String::new(),
1919 }))
1920}
1921
1922pub fn auto_parse(
1935 text: &str,
1936 lang: &str,
1937 prefer: &HashMap<String, String>,
1938 thousands_sep: Option<&str>,
1939 decimal_sep: Option<&str>,
1940) -> Result<Quantity, W2nError> {
1941 let raw = text;
1942 let text = py_strip(text);
1943 if text.is_empty() {
1944 return Err(W2nError::Words2Num("empty input".to_string()));
1945 }
1946 let chars: Vec<char> = text.chars().collect();
1947
1948 if let Some((sym, num_str, scale)) = try_currency_prefix(&chars) {
1950 match (|| -> Result<Quantity, W2nError> {
1953 let mut value = parse_number_string(&num_str, thousands_sep, decimal_sep, Some(lang))?;
1954 if let Some(s) = scale.as_deref().filter(|s| !s.is_empty()) {
1955 value = apply_scale(value, s)?;
1956 }
1957 Ok(quantity_currency(value, &sym, raw))
1958 })() {
1959 Ok(q) => return Ok(q),
1960 Err(W2nError::Words2Num(_)) => {}
1961 Err(e) => return Err(e),
1962 }
1963 }
1964
1965 if let Some((sym, num_str)) = try_currency_suffix(&chars) {
1967 match parse_number_string(&num_str, thousands_sep, decimal_sep, Some(lang)) {
1968 Ok(value) => return Ok(quantity_currency(value, &sym, raw)),
1969 Err(W2nError::Words2Num(_)) => {}
1970 Err(e) => return Err(e),
1971 }
1972 }
1973
1974 if let Some(mut res) = try_digit_unit(&chars, prefer, thousands_sep, decimal_sep, lang)? {
1976 res.raw = raw.to_string();
1977 return Ok(res);
1978 }
1979
1980 if let Some(mut res) = try_word_unit(text, lang, prefer)? {
1982 res.raw = raw.to_string();
1983 return Ok(res);
1984 }
1985
1986 match parse_number_string(text, thousands_sep, decimal_sep, Some(lang)) {
1988 Ok(value) => return Ok(Quantity::bare(value, raw)),
1989 Err(W2nError::Words2Num(_)) => {}
1990 Err(e) => return Err(e),
1991 }
1992
1993 match call_words2num(text, lang) {
1994 Ok(v) => Ok(Quantity::bare(v, raw)),
1995 Err(e) => Err(W2nError::Words2Num(format!(
1997 "could not auto-parse {}: {}",
1998 py_repr_str(raw),
1999 e.message()
2000 ))),
2001 }
2002}
2003
2004fn eat_num(cur: &mut Cursor) -> Option<String> {
2014 let start = cur.i;
2015 cur.eat_if(|c| c == '-' || c == '+');
2016 if !cur.peek().is_some_and(is_unicode_digit) {
2017 cur.i = start;
2018 return None;
2019 }
2020 cur.take_while(is_unicode_digit);
2021 loop {
2023 let save = cur.i;
2024 if !cur.eat_if(is_num_sep) {
2025 break;
2026 }
2027 if !cur.peek().is_some_and(is_unicode_digit) {
2028 cur.i = save;
2029 break;
2030 }
2031 cur.take_while(is_unicode_digit);
2032 }
2033 Some(cur.c[start..cur.i].iter().collect())
2034}
2035
2036fn match_quantity_at(chars: &[char], pos: usize) -> Option<usize> {
2047 {
2049 let mut cur = Cursor {
2050 c: chars,
2051 i: pos,
2052 };
2053 if cur.peek().is_some_and(is_currency_symbol) {
2054 cur.i += 1;
2055 cur.skip_ws();
2056 if eat_num(&mut cur).is_some() {
2057 let save = cur.i;
2058 cur.skip_ws();
2059 if cur.eat_if(|c| "kKmMbBtT".contains(c)) {
2060 cur.eat_if(|c| c == 'n' || c == 'N');
2061 } else {
2062 cur.i = save;
2063 }
2064 return Some(cur.i);
2065 }
2066 }
2067 }
2068 {
2070 let mut cur = Cursor {
2071 c: chars,
2072 i: pos,
2073 };
2074 if eat_num(&mut cur).is_some() {
2075 let save = cur.i;
2076 cur.skip_ws();
2077 let matched = if cur.peek() == Some('\u{b0}') {
2078 cur.i += 1;
2079 cur.eat_if(|c| c == 'C' || c == 'F');
2080 true
2081 } else if cur.peek() == Some('%') || cur.peek().is_some_and(is_currency_symbol) {
2082 cur.i += 1;
2084 true
2085 } else if (0..3).all(|k| cur.at(k).is_some_and(|c| c.is_ascii_uppercase())) {
2086 cur.i += 3;
2087 true
2088 } else if cur
2089 .peek()
2090 .is_some_and(|c| c.is_ascii_alphabetic() || c == '\u{b5}')
2091 {
2092 cur.take_while(|c| c.is_ascii_alphabetic() || c == '\u{b5}');
2093 true
2094 } else {
2095 false
2096 };
2097 if !matched {
2098 cur.i = save;
2099 }
2100 return Some(cur.i);
2101 }
2102 }
2103 None
2104}
2105
2106fn format_number(value: &W2nValue) -> String {
2113 if let W2nValue::Float(f) = value {
2114 if f.is_finite() && f.fract() == 0.0 {
2116 if *f == 0.0 {
2117 return "0".to_string(); }
2119 if let Some(i) = BigInt::from_f64(*f) {
2120 return i.to_string();
2121 }
2122 }
2123 }
2124 value.py_str()
2125}
2126
2127fn irregular_plural(long_form: &str) -> Option<&'static str> {
2129 Some(match long_form {
2130 "foot" => "feet",
2131 "inch" => "inches",
2132 "pound sterling" => "pounds sterling",
2133 "degree celsius" => "degrees celsius",
2134 "degree fahrenheit" => "degrees fahrenheit",
2135 "Swiss franc" => "Swiss francs",
2136 "Canadian dollar" => "Canadian dollars",
2137 "Australian dollar" => "Australian dollars",
2138 "Mexican peso" => "Mexican pesos",
2139 "US dollar" => "US dollars",
2140 _ => return None,
2141 })
2142}
2143
2144fn is_uncountable(long_form: &str) -> bool {
2146 matches!(long_form, "yen" | "yuan" | "won" | "kelvin" | "percent")
2147}
2148
2149pub fn pluralize(long_form: Option<&str>, value: &W2nValue) -> Option<String> {
2151 let long_form = long_form?;
2152 if value.is_unit_magnitude() {
2154 return Some(long_form.to_string());
2155 }
2156 if is_uncountable(long_form) {
2157 return Some(long_form.to_string());
2158 }
2159 if let Some(p) = irregular_plural(long_form) {
2160 return Some(p.to_string());
2161 }
2162 if long_form.ends_with('s')
2163 || long_form.ends_with('x')
2164 || long_form.ends_with('z')
2165 || long_form.ends_with("ch")
2166 || long_form.ends_with("sh")
2167 {
2168 return Some(format!("{}es", long_form));
2169 }
2170 let chars: Vec<char> = long_form.chars().collect();
2171 if chars.last() == Some(&'y') && chars.len() >= 2 && !"aeiou".contains(chars[chars.len() - 2]) {
2172 let stem: String = chars[..chars.len() - 1].iter().collect();
2173 return Some(format!("{}ies", stem));
2174 }
2175 Some(format!("{}s", long_form))
2176}
2177
2178fn format_quantity(q: &Quantity, expand: bool) -> String {
2180 let Some(unit) = q.unit.as_deref() else {
2181 return format_number(&q.value);
2182 };
2183 if expand {
2184 let label = pluralize(q.unit_long.as_deref(), &q.value);
2185 return format!(
2187 "{} {}",
2188 format_number(&q.value),
2189 label.unwrap_or_else(|| "None".to_string())
2190 );
2191 }
2192 if unit == "%" || unit == "\u{b0}" {
2194 return format!("{}{}", format_number(&q.value), unit);
2195 }
2196 format!("{} {}", format_number(&q.value), unit)
2197}
2198
2199pub fn auto_parse_sentence(
2206 text: &str,
2207 lang: &str,
2208 prefer: &HashMap<String, String>,
2209 thousands_sep: Option<&str>,
2210 decimal_sep: Option<&str>,
2211 expand: bool,
2212) -> Result<String, W2nError> {
2213 let chars: Vec<char> = text.chars().collect();
2214 let mut out = String::new();
2215 let mut i = 0usize;
2216 while i < chars.len() {
2217 match match_quantity_at(&chars, i) {
2218 Some(end) if end > i => {
2221 let raw: String = chars[i..end].iter().collect();
2222 match auto_parse(&raw, lang, prefer, thousands_sep, decimal_sep) {
2223 Ok(q) => out.push_str(&format_quantity(&q, expand)),
2224 Err(W2nError::Words2Num(_)) => out.push_str(&raw),
2225 Err(e) => return Err(e),
2226 }
2227 i = end;
2228 }
2229 _ => {
2230 out.push(chars[i]);
2231 i += 1;
2232 }
2233 }
2234 }
2235 Ok(out)
2236}
2237
2238#[cfg(test)]
2239mod tests {
2240 use super::*;
2241
2242 fn int(n: i64) -> W2nValue {
2243 W2nValue::Int(BigInt::from(n))
2244 }
2245
2246 #[test]
2247 fn resolve_lang_rules() {
2248 assert_eq!(resolve_lang("en").unwrap(), "en");
2249 assert_eq!(resolve_lang("en-US").unwrap(), "en"); assert_eq!(resolve_lang("zh").unwrap(), "zh");
2251 assert!(resolve_lang("xx").is_err());
2252 }
2253
2254 #[test]
2255 fn converter_en_cardinal_ordinal_year() {
2256 let c = converter_for("en");
2257 assert_eq!(c.to_cardinal("forty-two").unwrap(), int(42));
2258 assert_eq!(c.to_ordinal("twenty-first").unwrap(), int(21));
2259 assert_eq!(c.to_year("nineteen ninety nine").unwrap(), int(1999));
2260 assert!(c.to_cardinal("minus").is_err()); }
2262
2263 #[test]
2264 fn walker_matches_python() {
2265 assert_eq!(
2266 words2num_sentence("I bought twenty-three apples.", "en", "cardinal", false).unwrap(),
2267 "I bought 23 apples."
2268 );
2269 assert_eq!(
2270 words2num_sentence(
2271 "In nineteen ninety nine, two thousand people came.",
2272 "en",
2273 "year",
2274 false
2275 )
2276 .unwrap(),
2277 "In 1999, 2000 people came."
2278 );
2279 assert_eq!(
2281 words2num_sentence("minus forty two", "en", "cardinal", false).unwrap(),
2282 "minus 42"
2283 );
2284 assert_eq!(
2286 words2num_sentence("point five", "en", "cardinal", false).unwrap(),
2287 "0.5"
2288 );
2289 }
2290
2291 #[test]
2292 fn auto_parse_currency_and_units() {
2293 let prefer = HashMap::new();
2294 let q = auto_parse("$12.50", "en", &prefer, None, None).unwrap();
2295 assert_eq!(q.value, W2nValue::Float(12.5));
2296 assert_eq!(q.unit.as_deref(), Some("USD"));
2297 assert_eq!(q.kind.as_deref(), Some("currency"));
2298
2299 let q = auto_parse("forty-two kg", "en", &prefer, None, None).unwrap();
2300 assert_eq!(q.value, int(42));
2301 assert_eq!(q.unit.as_deref(), Some("kg"));
2302
2303 let q = auto_parse("$5bn", "en", &prefer, None, None).unwrap();
2304 assert_eq!(q.value, int(5_000_000_000));
2305
2306 assert_eq!(
2308 auto_parse("$5kn", "en", &prefer, None, None),
2309 Err(W2nError::Key("kn".to_string()))
2310 );
2311 }
2312
2313 #[test]
2314 fn auto_parse_sentence_rewrites() {
2315 let prefer = HashMap::new();
2316 let out = auto_parse_sentence(
2317 "The package weighs 5kg and costs $12.50.",
2318 "en",
2319 &prefer,
2320 None,
2321 None,
2322 false,
2323 )
2324 .unwrap();
2325 assert!(out.contains("5 kg"));
2326 assert!(out.contains("12.5 USD"));
2327 }
2328
2329 #[test]
2330 fn pluralize_rules() {
2331 assert_eq!(pluralize(Some("dollar"), &int(5)).as_deref(), Some("dollars"));
2332 assert_eq!(pluralize(Some("dollar"), &int(1)).as_deref(), Some("dollar"));
2333 assert_eq!(pluralize(Some("foot"), &int(5)).as_deref(), Some("feet"));
2334 assert_eq!(pluralize(Some("yen"), &int(5)).as_deref(), Some("yen"));
2335 }
2336}