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 and IDs are captured
6//! whole before numbers, whitespace, emails, 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}(?:$|\s)"),
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: [&Regex; 7] = [
98            &re.urls,
99            &re.ids,
100            &re.time,
101            &re.number,
102            &re.space,
103            &re.email,
104            &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/// One node in the recursive split tree.
114///
115/// A node holds the delimiters its regex matched and its children. At the last
116/// regex level children are raw gap strings. Above that they are child nodes.
117enum LexerNode {
118    /// Interior node: matched delimiters plus child nodes for each gap.
119    Branch {
120        matches: Vec<String>,
121        children: Vec<LexerNode>,
122    },
123    /// Leaf node: matched delimiters plus raw gap strings.
124    Leaf {
125        matches: Vec<String>,
126        gaps: Vec<String>,
127    },
128}
129
130impl LexerNode {
131    /// Build a node from `string`, splitting on `regex` and recursing on the
132    /// remaining regexes.
133    fn new(string: &str, regex: &Regex, rest: &[&Regex]) -> LexerNode {
134        let string = js_trim(string);
135
136        let (matches, gaps) = if string.is_empty() {
137            (Vec::new(), vec![string])
138        } else {
139            let matches: Vec<String> = regex
140                .find_iter(&string)
141                .map(|m| m.as_str().to_string())
142                .collect();
143            if matches.is_empty() {
144                (Vec::new(), vec![string])
145            } else {
146                let gaps = split_keep_gaps(regex, &string);
147                (matches, gaps)
148            }
149        };
150
151        if rest.is_empty() {
152            LexerNode::Leaf { matches, gaps }
153        } else {
154            let next = rest[0];
155            let next_rest = &rest[1..];
156            let children = gaps
157                .iter()
158                .map(|gap| LexerNode::new(gap, next, next_rest))
159                .collect();
160            LexerNode::Branch { matches, children }
161        }
162    }
163
164    /// Flatten this node into `out`, interleaving gap then delimiter.
165    ///
166    /// For each child index `i`, emit the child's tokens, then if a delimiter
167    /// `matches[i]` exists and is not blank, emit it trimmed. Blank children are
168    /// dropped.
169    fn fill_array(&self, out: &mut Vec<String>, unblank: &Regex) {
170        match self {
171            LexerNode::Branch { matches, children } => {
172                for (i, child) in children.iter().enumerate() {
173                    child.fill_array(out, unblank);
174                    if i < matches.len() && unblank.is_match(&matches[i]) {
175                        out.push(js_trim(&matches[i]));
176                    }
177                }
178            }
179            LexerNode::Leaf { matches, gaps } => {
180                for (i, gap) in gaps.iter().enumerate() {
181                    if unblank.is_match(gap) {
182                        out.push(js_trim(gap));
183                    }
184                    if i < matches.len() && unblank.is_match(&matches[i]) {
185                        out.push(js_trim(&matches[i]));
186                    }
187                }
188            }
189        }
190    }
191}
192
193/// Split `s` on `regex`, dropping the delimiters, like JavaScript `String.split`
194/// with a non-capturing pattern.
195///
196/// JavaScript emits empty strings for matches at the start or end and for
197/// adjacent matches. The Rust `regex` split iterator does the same, so the
198/// result has `matches.len() + 1` entries when there are matches.
199fn split_keep_gaps(regex: &Regex, s: &str) -> Vec<String> {
200    regex.split(s).map(|piece| piece.to_string()).collect()
201}
202
203/// Trim using the JavaScript `String.trim` whitespace set.
204///
205/// This covers ASCII whitespace plus the Unicode space separators, the line and
206/// paragraph separators, the no-break space, and the byte order mark. For ASCII
207/// input it matches `str::trim`.
208fn js_trim(s: &str) -> String {
209    s.trim_matches(is_js_whitespace).to_string()
210}
211
212/// True if `c` is whitespace under JavaScript `String.trim` rules.
213fn is_js_whitespace(c: char) -> bool {
214    matches!(
215        c,
216        '\u{0009}'
217            | '\u{000A}'
218            | '\u{000B}'
219            | '\u{000C}'
220            | '\u{000D}'
221            | '\u{0020}'
222            | '\u{00A0}'
223            | '\u{1680}'
224            | '\u{2000}'
225            ..='\u{200A}'
226                | '\u{2028}'
227                | '\u{2029}'
228                | '\u{202F}'
229                | '\u{205F}'
230                | '\u{3000}'
231                | '\u{FEFF}'
232    )
233}