Skip to main content

pos_tagger/
lexer.rs

1//! Recursive regex tokenizer.
2//!
3//! The lexer splits a string against an ordered list of seven regexes. Each
4//! level keeps the matched delimiters as their own tokens and re-splits the gaps
5//! with the remaining regexes. Order sets precedence: URLs, IDs, and emails are
6//! captured whole before numbers, whitespace, and single punctuation marks get a
7//! chance to split.
8
9use regex::Regex;
10use std::sync::OnceLock;
11
12/// Whitespace character class shared by every splitting regex.
13///
14/// This is the JavaScript whitespace set, the same code points
15/// [`is_js_whitespace`] encodes. It differs from Rust's Unicode `\s` on two
16/// code points. It excludes U+0085 (NEL) and includes U+FEFF (BOM). Keeping one
17/// definition means trim and split agree on what whitespace is.
18const WS_CLASS: &str = concat!(
19    "\\t\\n\\x0B\\x0C\\r",
20    " ",
21    "\\u{00A0}\\u{1680}\\u{2000}-\\u{200A}",
22    "\\u{2028}\\u{2029}\\u{202F}\\u{205F}\\u{3000}\\u{FEFF}",
23);
24
25/// Build a regex from `template`, substituting `\s` with the JS whitespace class
26/// and `\S` with its negation.
27fn js_ws_regex(template: &str) -> Regex {
28    let positive = format!("[{WS_CLASS}]");
29    let negative = format!("[^{WS_CLASS}]");
30    let pattern = template.replace(r"\s", &positive).replace(r"\S", &negative);
31    Regex::new(&pattern).expect("lexer regex compiles")
32}
33
34/// The active splitting regexes, in precedence order.
35struct Regexes {
36    urls: Regex,
37    ids: Regex,
38    time: Regex,
39    number: Regex,
40    space: Regex,
41    email: Regex,
42    punctuation: Regex,
43    unblank: Regex,
44}
45
46fn regexes() -> &'static Regexes {
47    static REGEXES: OnceLock<Regexes> = OnceLock::new();
48    REGEXES.get_or_init(|| {
49        // `(?i)` is case-insensitive. The URL pattern spells out ASCII character
50        // classes instead of `\w` and `\d` so it matches ASCII word and digit
51        // code points only, the same set JavaScript's ASCII classes cover.
52        Regexes {
53            urls: Regex::new(
54                r"(?i)(?:https?://)(?:[0-9a-z.-]+)\.(?:[a-z.]{2,6})(?:[/0-9A-Za-z_.\-?#=&%]*)*/?",
55            )
56            .expect("urls regex compiles"),
57            ids: js_ws_regex(r"(?i)(?:^|\s)([a-z0-9-]{8,45})"),
58            time: js_ws_regex(r"(?i)(?:[0-9]|0[0-9]|1[0-9]|2[0-3]):(?:[0-5][0-9])\s?(?:[ap][m])"),
59            number: Regex::new(r"(?i)[0-9]*\.[0-9]+|[0-9]+").expect("number regex compiles"),
60            space: js_ws_regex(r"\s+"),
61            email: Regex::new(
62                r#"(?i)[-!#$%&'*+/0-9=?A-Z^_a-z{|}~](?:\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](?:-?\.?[a-zA-Z0-9])*(?:\.[a-zA-Z](?:-?[a-zA-Z0-9])*)+"#,
63            )
64            .expect("email regex compiles"),
65            punctuation: Regex::new(r#"[/.,?!"':;$()#’`]"#).expect("punctuation regex compiles"),
66            unblank: js_ws_regex(r"\S"),
67        }
68    })
69}
70
71/// An English-aware tokenizer.
72///
73/// Construct one with [`Lexer::new`] and call [`Lexer::lex`]. The struct holds no
74/// state, so a single instance is reusable and cheap to create.
75///
76/// ```
77/// use pos_tagger::Lexer;
78/// let lexer = Lexer::new();
79/// assert_eq!(lexer.lex("I made $5.42 today."), vec!["I", "made", "$", "5.42", "today", "."]);
80/// ```
81#[derive(Debug, Default, Clone, Copy)]
82pub struct Lexer;
83
84impl Lexer {
85    /// Create a lexer.
86    pub fn new() -> Self {
87        Lexer
88    }
89
90    /// Split `string` into tokens.
91    ///
92    /// Returns tokens in textual order. URLs, emails, long IDs, times, and
93    /// numbers stay whole. Single punctuation marks split off as their own
94    /// tokens. Whitespace-only fragments are dropped and every token is trimmed.
95    pub fn lex(&self, string: &str) -> Vec<String> {
96        let re = regexes();
97        let order = [
98            SplitRule::new(&re.urls),
99            SplitRule::id(&re.ids),
100            SplitRule::new(&re.time),
101            SplitRule::new(&re.email),
102            SplitRule::new(&re.number),
103            SplitRule::new(&re.space),
104            SplitRule::new(&re.punctuation),
105        ];
106        let root = LexerNode::new(string, order[0], &order[1..]);
107        let mut out = Vec::new();
108        root.fill_array(&mut out, &re.unblank);
109        out
110    }
111}
112
113#[derive(Clone, Copy)]
114struct SplitRule<'a> {
115    regex: &'a Regex,
116    id_capture: bool,
117}
118
119impl<'a> SplitRule<'a> {
120    fn new(regex: &'a Regex) -> Self {
121        Self {
122            regex,
123            id_capture: false,
124        }
125    }
126
127    fn id(regex: &'a Regex) -> Self {
128        Self {
129            regex,
130            id_capture: true,
131        }
132    }
133}
134
135/// One node in the recursive split tree.
136///
137/// A node holds the delimiters its regex matched and its children. At the last
138/// regex level children are raw gap strings. Above that they are child nodes.
139enum LexerNode {
140    /// Interior node: matched delimiters plus child nodes for each gap.
141    Branch {
142        matches: Vec<String>,
143        children: Vec<LexerNode>,
144    },
145    /// Leaf node: matched delimiters plus raw gap strings.
146    Leaf {
147        matches: Vec<String>,
148        gaps: Vec<String>,
149    },
150}
151
152impl LexerNode {
153    /// Build a node from `string`, splitting on `regex` and recursing on the
154    /// remaining regexes.
155    fn new(string: &str, rule: SplitRule<'_>, rest: &[SplitRule<'_>]) -> LexerNode {
156        let string = js_trim(string);
157
158        let (matches, gaps) = if string.is_empty() {
159            (Vec::new(), vec![string])
160        } else {
161            let (matches, gaps) = split_with_rule(rule, &string);
162            if matches.is_empty() {
163                (Vec::new(), vec![string])
164            } else {
165                (matches, gaps)
166            }
167        };
168
169        if rest.is_empty() {
170            LexerNode::Leaf { matches, gaps }
171        } else {
172            let next = rest[0];
173            let next_rest = &rest[1..];
174            let children = gaps
175                .iter()
176                .map(|gap| LexerNode::new(gap, next, next_rest))
177                .collect();
178            LexerNode::Branch { matches, children }
179        }
180    }
181
182    /// Flatten this node into `out`, interleaving gap then delimiter.
183    ///
184    /// For each child index `i`, emit the child's tokens, then if a delimiter
185    /// `matches[i]` exists and is not blank, emit it trimmed. Blank children are
186    /// dropped.
187    fn fill_array(&self, out: &mut Vec<String>, unblank: &Regex) {
188        match self {
189            LexerNode::Branch { matches, children } => {
190                for (i, child) in children.iter().enumerate() {
191                    child.fill_array(out, unblank);
192                    if i < matches.len() && unblank.is_match(&matches[i]) {
193                        out.push(js_trim(&matches[i]));
194                    }
195                }
196            }
197            LexerNode::Leaf { matches, gaps } => {
198                for (i, gap) in gaps.iter().enumerate() {
199                    if unblank.is_match(gap) {
200                        out.push(js_trim(gap));
201                    }
202                    if i < matches.len() && unblank.is_match(&matches[i]) {
203                        out.push(js_trim(&matches[i]));
204                    }
205                }
206            }
207        }
208    }
209}
210
211/// Split `s` with a rule, dropping delimiters from gaps.
212///
213/// Regular rules behave like JavaScript `String.split` with a non-capturing
214/// pattern. The ID rule uses capture group 1 as the delimiter so trailing
215/// punctuation remains in a gap.
216fn split_with_rule(rule: SplitRule<'_>, s: &str) -> (Vec<String>, Vec<String>) {
217    if rule.id_capture {
218        split_id_matches(rule.regex, s)
219    } else {
220        let matches: Vec<String> = rule
221            .regex
222            .find_iter(s)
223            .map(|m| m.as_str().to_string())
224            .collect();
225        let gaps = rule.regex.split(s).map(|piece| piece.to_string()).collect();
226        (matches, gaps)
227    }
228}
229
230fn split_id_matches(regex: &Regex, s: &str) -> (Vec<String>, Vec<String>) {
231    let mut matches = Vec::new();
232    let mut gaps = Vec::new();
233    let mut last_end = 0;
234
235    for capture in regex.captures_iter(s) {
236        let Some(body) = capture.get(1) else {
237            continue;
238        };
239
240        if !is_id_boundary(s, body.end()) {
241            continue;
242        }
243
244        gaps.push(s[last_end..body.start()].to_string());
245        matches.push(body.as_str().to_string());
246        last_end = body.end();
247    }
248
249    if matches.is_empty() {
250        (matches, Vec::new())
251    } else {
252        gaps.push(s[last_end..].to_string());
253        (matches, gaps)
254    }
255}
256
257fn is_id_boundary(s: &str, end: usize) -> bool {
258    let mut chars = s[end..].chars();
259    match chars.next() {
260        None => true,
261        Some(c) if is_js_whitespace(c) => true,
262        Some('.') => !matches!(chars.next(), Some(next) if next.is_ascii_digit()),
263        Some(c) => is_id_punctuation(c),
264    }
265}
266
267fn is_id_punctuation(c: char) -> bool {
268    matches!(
269        c,
270        '/' | ',' | '?' | '!' | '"' | '\'' | ':' | ';' | '$' | '(' | ')' | '#' | '’' | '`'
271    )
272}
273
274/// Trim using the JavaScript `String.trim` whitespace set.
275///
276/// This covers ASCII whitespace plus the Unicode space separators, the line and
277/// paragraph separators, the no-break space, and the byte order mark. For ASCII
278/// input it matches `str::trim`.
279fn js_trim(s: &str) -> String {
280    s.trim_matches(is_js_whitespace).to_string()
281}
282
283/// True if `c` is whitespace under JavaScript `String.trim` rules.
284fn is_js_whitespace(c: char) -> bool {
285    matches!(
286        c,
287        '\u{0009}'
288            | '\u{000A}'
289            | '\u{000B}'
290            | '\u{000C}'
291            | '\u{000D}'
292            | '\u{0020}'
293            | '\u{00A0}'
294            | '\u{1680}'
295            | '\u{2000}'
296            ..='\u{200A}'
297                | '\u{2028}'
298                | '\u{2029}'
299                | '\u{202F}'
300                | '\u{205F}'
301                | '\u{3000}'
302                | '\u{FEFF}'
303    )
304}