Skip to main content

words2num2_core/
lib.rs

1//! Pure-Rust core for words2num2 — the inverse of num2words2.
2//!
3//! # Why this is small
4//!
5//! 119 of words2num2's 120 locales never had a hand-written parser. They use
6//! `Words2Num_Base`, which materialises a reverse lookup table by calling
7//! `num2words` across `LOOKUP_RANGE` (`range(-1, 10001)`) — 10,002 renders —
8//! and then does a dict hit. Only `en` is hand-written.
9//!
10//! So the port is: the generic table backend + `_normalize` + the `en`
11//! grammar parser. The table is built by calling the Rust num2words core
12//! (`num2words2-core`) directly, which is where the speedup comes from.
13//!
14//! This crate has **no** PyO3 dependency: it is the pure-Rust engine. The
15//! `words2num2-py` crate is a thin PyO3 binder over the public API here.
16
17use num_bigint::BigInt;
18use std::collections::HashMap;
19use std::sync::{OnceLock, RwLock};
20
21pub mod w2n_formats;
22pub mod w2n_lang_en;
23pub mod w2n_sentence;
24
25/// Python's `Words2Num_Base.LOOKUP_RANGE`.
26const LOOKUP_LO: i64 = -1;
27const LOOKUP_HI: i64 = 10001;
28
29/// Error from the reverse-table backend.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum LookupError {
32    /// num2words2 has no backend for this locale key
33    /// (Python raised `NotImplementedError(lang)`).
34    NotImplemented(String),
35}
36
37/// Port of the tail of `Words2Num_Base._normalize` — the pure ASCII-shaped
38/// rewriting that follows NFKD decomposition + combining-mark stripping.
39///
40/// ```python
41/// text = text.lower().replace("_", " ")
42/// text = re.sub(r"(?<=[a-z])-(?=[a-z])", " ", text)
43/// text = re.sub(r"[,;:!\?\"']", " ", text)
44/// text = re.sub(r"\.(?!\d)", " ", text)
45/// text = re.sub(r"\s+", " ", text).strip()
46/// ```
47pub fn normalize_tail(decomposed: &str) -> String {
48    let lowered = decomposed.to_lowercase().replace('_', " ");
49    let chars: Vec<char> = lowered.chars().collect();
50    let mut out = String::with_capacity(lowered.len());
51
52    for (i, &c) in chars.iter().enumerate() {
53        match c {
54            // (?<=[a-z])-(?=[a-z]) — a hyphen joining two words becomes a
55            // space, but a hyphen before a digit is a sign and must survive.
56            '-' => {
57                let prev_alpha = i > 0 && chars[i - 1].is_ascii_lowercase();
58                let next_alpha = chars.get(i + 1).is_some_and(|n| n.is_ascii_lowercase());
59                out.push(if prev_alpha && next_alpha { ' ' } else { '-' });
60            }
61            ',' | ';' | ':' | '!' | '?' | '"' | '\'' => out.push(' '),
62            // \.(?!\d) — sentence-final dot goes, decimal point stays.
63            '.' => {
64                let next_digit = chars.get(i + 1).is_some_and(|n| n.is_ascii_digit());
65                out.push(if next_digit { '.' } else { ' ' });
66            }
67            _ => out.push(c),
68        }
69    }
70    // \s+ -> " ", then strip.
71    out.split_whitespace().collect::<Vec<_>>().join(" ")
72}
73
74/// Pure-Rust port of `Words2Num_Base._normalize`.
75///
76/// ```python
77/// nfkd = unicodedata.normalize("NFKD", text)
78/// text = "".join(c for c in nfkd if not unicodedata.combining(c))
79/// # ... normalize_tail ...
80/// ```
81///
82/// The NFKD decomposition + combining-mark strip is what makes "trente-deux"
83/// match "trente deux". It is done here with the `unicode-normalization`
84/// crate rather than round-tripping to Python's `unicodedata`, so the core is
85/// self-contained.
86pub fn normalize(s: &str) -> String {
87    use unicode_normalization::UnicodeNormalization;
88    let nfkd: String = s.nfkd().collect();
89    let stripped: String = nfkd
90        .chars()
91        .filter(|&c| !unicode_normalization::char::is_combining_mark(c))
92        .collect();
93    normalize_tail(&stripped)
94}
95
96/// One reverse table: `{normalized_words: number}`.
97type ReverseTable = HashMap<String, i64>;
98/// The per-(lang, kind) cache of reverse tables.
99type TableCache = RwLock<HashMap<(String, bool), ReverseTable>>;
100
101/// The reverse tables, built lazily per (lang, kind) exactly as Python does.
102fn tables() -> &'static TableCache {
103    static T: OnceLock<TableCache> = OnceLock::new();
104    T.get_or_init(|| RwLock::new(HashMap::new()))
105}
106
107/// Port of `Words2Num_Base._build_table`.
108///
109/// Python calls `num2words(n, lang, to=kind)` for every n in LOOKUP_RANGE and
110/// does `table.setdefault(key, n)` — **first write wins**, so the canonical
111/// short form takes precedence over later spellings. That ordering is
112/// load-bearing; do not switch to insert-overwrite.
113fn build_table(lang: &str, ordinal: bool) -> Result<HashMap<String, i64>, LookupError> {
114    let l = num2words2_core::get_lang_by_key(lang)
115        .ok_or_else(|| LookupError::NotImplemented(lang.to_string()))?;
116    let mut table = HashMap::new();
117    for n in LOOKUP_LO..LOOKUP_HI {
118        let v = BigInt::from(n);
119        let words = if ordinal { l.to_ordinal(&v) } else { l.to_cardinal(&v) };
120        let Ok(words) = words else { continue }; // Python swallows every raise
121        let key = normalize(&words);
122        table.entry(key).or_insert(n);
123    }
124    Ok(table)
125}
126
127/// Port of `Words2Num_Base._lookup` + `to_cardinal`/`to_ordinal`.
128///
129/// Returns `None` when the text is not in the table, so the Python side can
130/// raise `Words2NumError` with its exact message rather than us guessing it.
131pub fn lookup(
132    lang: &str,
133    text: &str,
134    ordinal: bool,
135    negative_words: &[String],
136) -> Result<Option<i64>, LookupError> {
137    let mut normalized = normalize(text);
138    if normalized.is_empty() {
139        return Ok(None);
140    }
141
142    let mut sign = 1i64;
143    for neg in negative_words {
144        if normalized == *neg {
145            return Ok(None); // a bare negword is unparseable
146        }
147        if let Some(rest) = normalized.strip_prefix(&format!("{} ", neg)) {
148            sign = -1;
149            normalized = rest.to_string();
150            break;
151        }
152    }
153
154    let key = (lang.to_string(), ordinal);
155    {
156        let t = tables().read().unwrap();
157        if let Some(tab) = t.get(&key) {
158            return Ok(tab.get(&normalized).map(|v| sign * v));
159        }
160    }
161    let built = build_table(lang, ordinal)?;
162    let got = built.get(&normalized).map(|v| sign * v);
163    tables().write().unwrap().insert(key, built);
164    Ok(got)
165}
166
167/// Languages the Rust core can serve (Python's `_RUST.supported_langs()`).
168pub fn supported_langs() -> Vec<&'static str> {
169    num2words2_core::supported_lang_keys()
170}
171
172/// Port of `_rust.parse_int` — a plain `int(s)` for a signed ASCII integer.
173pub fn parse_int(s: &str) -> Result<i64, std::num::ParseIntError> {
174    s.parse::<i64>()
175}
176
177// ---------------------------------------------------------------------------
178// English grammar entry points (pure)
179// ---------------------------------------------------------------------------
180
181/// `Words2Num_EN().to_cardinal(text)`.
182pub fn en_to_cardinal(text: &str) -> Result<w2n_lang_en::W2nValue, w2n_lang_en::W2nError> {
183    w2n_lang_en::W2nLangEn::new().to_cardinal(text)
184}
185
186/// `Words2Num_EN().to_ordinal(text)`.
187pub fn en_to_ordinal(text: &str) -> Result<w2n_lang_en::W2nValue, w2n_lang_en::W2nError> {
188    w2n_lang_en::W2nLangEn::new().to_ordinal(text)
189}
190
191/// `Words2Num_EN().to_year(text)`.
192pub fn en_to_year(text: &str) -> Result<w2n_lang_en::W2nValue, w2n_lang_en::W2nError> {
193    w2n_lang_en::W2nLangEn::new().to_year(text)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn normalize_matches_python_shape() {
202        // Hyphen between letters -> space; trailing punct -> space; collapse.
203        assert_eq!(normalize("Forty-Two"), "forty two");
204        assert_eq!(normalize("  a_b  "), "a b");
205        assert_eq!(normalize("hello."), "hello");
206        assert_eq!(normalize("3.14"), "3.14"); // decimal point survives
207        assert_eq!(normalize("-17"), "-17"); // sign hyphen survives
208        // NFKD + combining-mark strip: "trente-deux" family.
209        assert_eq!(normalize("tr\u{e9}nte"), "trente"); // é -> e
210        assert_eq!(normalize("f\u{f3}rty"), "forty"); // ó -> o
211    }
212
213    #[test]
214    fn en_entry_points() {
215        assert_eq!(
216            en_to_cardinal("forty-two").unwrap(),
217            w2n_lang_en::W2nValue::Int(BigInt::from(42))
218        );
219        assert_eq!(
220            en_to_ordinal("twenty-first").unwrap(),
221            w2n_lang_en::W2nValue::Int(BigInt::from(21))
222        );
223        assert_eq!(
224            en_to_year("nineteen ninety nine").unwrap(),
225            w2n_lang_en::W2nValue::Int(BigInt::from(1999))
226        );
227        assert!(en_to_cardinal("forty zoot").is_err());
228    }
229
230    #[test]
231    fn supported_langs_nonempty() {
232        let langs = supported_langs();
233        assert!(langs.contains(&"en"));
234        assert!(langs.contains(&"fr"));
235        assert!(langs.len() >= 100);
236    }
237}