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