english_tokenizer/lib.rs
1//! Regex-driven tokenizer that tags each token with its type.
2//!
3//! Give it a sentence and it returns an ordered list of tokens. Each token has a
4//! `value` and a `tag`. Tags name the token type: `word`, `number`, `ordinal`,
5//! `email`, `url`, `mention`, `hashtag`, `emoji`, `emoticon`, `time`, `currency`,
6//! `quoted_phrase`, `punctuation`, `symbol`, plus `alien` for text that no active
7//! rule matched.
8//!
9//! The tokenizer covers Latin-1 and Devanagari scripts. It splits common English
10//! contractions and possessives into separate word tokens. For example `I'll`
11//! becomes `I` and `'ll`, and `dog's` becomes `dog` and `'s`.
12//!
13//! # Example
14//!
15//! ```
16//! use english_tokenizer::Tokenizer;
17//!
18//! let mut tk = Tokenizer::new();
19//! let tokens = tk.tokenize("feeling good #FunTime");
20//! assert_eq!(tokens.len(), 3);
21//! assert_eq!(tokens[2].value, "#FunTime");
22//! assert_eq!(tokens[2].tag, "hashtag");
23//! ```
24//!
25//! # Configuration
26//!
27//! [`Tokenizer::new`] enables every token type except `quoted_phrase`. Use
28//! [`Tokenizer::define_config`] to turn types on or off. An empty config splits
29//! on spaces and tags everything `alien`.
30//!
31//! [`Tokenizer::add_regex`] injects a custom rule that wins over the built-ins.
32//! [`Tokenizer::add_tag`] registers a new tag with a fingerprint code.
33//! [`Tokenizer::get_tokens_fp`] returns a one character per token fingerprint of
34//! the last tokenize call.
35
36#![forbid(unsafe_code)]
37#![warn(missing_docs)]
38
39mod contractions;
40mod emoji;
41mod patterns;
42
43use regex::Regex;
44use std::collections::HashMap;
45
46use patterns::Rule;
47
48/// Built-in token tag names.
49///
50/// Use these constants instead of typing the string literals. A misspelled
51/// constant fails to compile. A misspelled literal compiles and silently
52/// matches nothing.
53///
54/// ```
55/// use english_tokenizer::{tags, Tokenizer};
56///
57/// let mut tk = Tokenizer::new();
58/// let tokens = tk.tokenize("hello");
59/// assert_eq!(tokens[0].tag, tags::WORD);
60///
61/// tk.define_config(&[(tags::HASHTAG, false)]);
62/// ```
63pub mod tags {
64 use crate::patterns;
65
66 /// `word`: a Latin or Devanagari word.
67 pub const WORD: &str = patterns::WORD_CAT;
68 /// `number`: an integer, decimal, fraction, or dotted run of digits.
69 pub const NUMBER: &str = patterns::NUMBER_CAT;
70 /// `ordinal`: an ordinal such as `1st` or `12th`.
71 pub const ORDINAL: &str = patterns::ORDINAL_CAT;
72 /// `email`: an email address.
73 pub const EMAIL: &str = patterns::EMAIL_CAT;
74 /// `url`: an http or https URL.
75 pub const URL: &str = patterns::URL_CAT;
76 /// `mention`: an `@name` mention.
77 pub const MENTION: &str = patterns::MENTION_CAT;
78 /// `hashtag`: a `#tag` hashtag.
79 pub const HASHTAG: &str = patterns::HASHTAG_CAT;
80 /// `emoji`: a unicode emoji.
81 pub const EMOJI: &str = patterns::EMOJI_CAT;
82 /// `emoticon`: a text emoticon such as `:-)`.
83 pub const EMOTICON: &str = patterns::EMOTICON_CAT;
84 /// `time`: a clock time such as `4pm` or `16:00 hours`.
85 pub const TIME: &str = patterns::TIME_CAT;
86 /// `currency`: a currency symbol.
87 pub const CURRENCY: &str = patterns::CURRENCY_CAT;
88 /// `quoted_phrase`: a double quoted span. Off by default.
89 pub const QUOTED_PHRASE: &str = patterns::QUOTED_PHRASE_CAT;
90 /// `punctuation`: a punctuation mark.
91 pub const PUNCTUATION: &str = patterns::PUNCTUATION_CAT;
92 /// `symbol`: a standalone symbol.
93 pub const SYMBOL: &str = patterns::SYMBOL_CAT;
94 /// `alien`: text that no active rule matched.
95 pub const ALIEN: &str = "alien";
96}
97
98/// A single token: its text and its type tag.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Token {
101 /// The matched text, exactly as it appeared in the input.
102 pub value: String,
103 /// The token type, for example `word`, `number`, or `punctuation`.
104 pub tag: String,
105}
106
107impl Token {
108 fn new(value: impl Into<String>, tag: impl Into<String>) -> Self {
109 Token {
110 value: value.into(),
111 tag: tag.into(),
112 }
113 }
114}
115
116/// Error returned when a tag or regex cannot be added.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum AddError {
119 /// A new tag was needed but no fingerprint code was given. `tag` is the name
120 /// the caller tried to add.
121 MissingFingerprint {
122 /// The tag the caller tried to add.
123 tag: String,
124 },
125 /// The tag already exists. `tag` is the name the caller tried to add.
126 TagExists {
127 /// The tag the caller tried to add.
128 tag: String,
129 },
130}
131
132impl std::fmt::Display for AddError {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 match self {
135 AddError::MissingFingerprint { tag } => write!(
136 f,
137 "Tag {tag} doesn't exist; Provide a 'fingerprintCode' to add it as a tag."
138 ),
139 AddError::TagExists { tag } => write!(f, "Tag {tag} already exists"),
140 }
141 }
142}
143
144impl std::error::Error for AddError {}
145
146/// The default fingerprint codes. `punctuation` and `symbol` have no code on
147/// purpose, so their tokens contribute their literal value to a fingerprint.
148fn default_fingerprint_codes() -> HashMap<String, String> {
149 let pairs = [
150 ("emoticon", "c"),
151 ("email", "e"),
152 ("emoji", "j"),
153 ("hashtag", "h"),
154 ("mention", "m"),
155 ("number", "n"),
156 ("ordinal", "o"),
157 ("quoted_phrase", "q"),
158 ("currency", "r"),
159 ("time", "t"),
160 ("url", "u"),
161 ("word", "w"),
162 ("alien", "z"),
163 ];
164 pairs
165 .iter()
166 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
167 .collect()
168}
169
170/// One result of [`tokenize_unit`](Tokenizer::tokenize_unit): either a finished
171/// token or a raw fragment that needs more tokenizing.
172enum Unit {
173 /// A finished token.
174 Token(Token),
175 /// A text fragment to recurse on.
176 Fragment(String),
177}
178
179/// A regex tokenizer instance.
180///
181/// Each instance holds its own rule list, fingerprint codes, and the result of
182/// the last tokenize call. Fingerprint codes are per instance. A
183/// [`define_config`](Self::define_config) call resets them. Create an instance
184/// with [`Tokenizer::new`].
185#[derive(Debug)]
186pub struct Tokenizer {
187 master: Vec<Rule>,
188 rgxs: Vec<Rule>,
189 final_tokens: Vec<Token>,
190 fingerprint_codes: HashMap<String, String>,
191 contraction: Regex,
192 pos_singular: Regex,
193 pos_plural: Regex,
194 spaces: Regex,
195}
196
197impl Default for Tokenizer {
198 fn default() -> Self {
199 Self::new()
200 }
201}
202
203impl Tokenizer {
204 /// Creates a tokenizer with every token type enabled except `quoted_phrase`.
205 ///
206 /// # Example
207 ///
208 /// ```
209 /// use english_tokenizer::Tokenizer;
210 /// let mut tk = Tokenizer::new();
211 /// assert!(tk.tokenize("").is_empty());
212 /// ```
213 pub fn new() -> Self {
214 let master = patterns::master();
215 let mut tk = Tokenizer {
216 // `define_config` below fills this from `master`.
217 rgxs: Vec::new(),
218 master,
219 final_tokens: Vec::new(),
220 fingerprint_codes: default_fingerprint_codes(),
221 contraction: Regex::new(r"'").expect("contraction pattern compiles"),
222 pos_singular: Regex::new(patterns::POS_SINGULAR).expect("possessive pattern compiles"),
223 pos_plural: Regex::new(patterns::POS_PLURAL).expect("possessive pattern compiles"),
224 spaces: Regex::new(patterns::SPACES).expect("spaces pattern compiles"),
225 };
226 // Quoted phrase is off by default since it is rarely needed.
227 tk.define_config(&[("quoted_phrase", false)]);
228 tk
229 }
230
231 /// Tokenizes `sentence` and returns the tokens in left to right order.
232 ///
233 /// The result is also kept for the next [`get_tokens_fp`](Self::get_tokens_fp)
234 /// call.
235 ///
236 /// # Example
237 ///
238 /// ```
239 /// use english_tokenizer::Tokenizer;
240 /// let mut tk = Tokenizer::new();
241 /// let tokens = tk.tokenize("4pm");
242 /// assert_eq!(tokens[0].tag, "time");
243 /// ```
244 pub fn tokenize(&mut self, sentence: &str) -> Vec<Token> {
245 self.final_tokens.clear();
246 // Move the rules out so `tokenize_recursive` can borrow them while it
247 // also holds `&mut self`. Put them back when the recursion returns.
248 let rules = std::mem::take(&mut self.rgxs);
249 self.tokenize_recursive(sentence, &rules);
250 self.rgxs = rules;
251 // Keep `final_tokens` for the next `get_tokens_fp` call. The clone here
252 // is the tokens only, not the compiled rule set.
253 self.final_tokens.clone()
254 }
255
256 /// Sets which token types are active and returns the count of unique active
257 /// categories.
258 ///
259 /// Each pair is a category name and a flag. A `true` flag enables the
260 /// category. A `false` flag disables it. Categories not listed stay enabled.
261 /// An empty list disables everything, so tokenize splits on spaces and tags
262 /// every piece `alien`.
263 ///
264 /// This also resets any rules added with [`add_regex`](Self::add_regex) and
265 /// resets the fingerprint codes.
266 ///
267 /// Categories that use more than one rule, like `hashtag` and `number`, count
268 /// once. The full default returns 13 because `quoted_phrase` is off. Enabling
269 /// `quoted_phrase` returns 14.
270 ///
271 /// # Example
272 ///
273 /// ```
274 /// use english_tokenizer::Tokenizer;
275 /// let mut tk = Tokenizer::new();
276 /// assert_eq!(tk.define_config(&[("hashtag", false)]), 13);
277 /// assert_eq!(tk.define_config(&[]), 0);
278 /// assert_eq!(tk.define_config(&[("quoted_phrase", true)]), 14);
279 /// ```
280 pub fn define_config(&mut self, config: &[(&str, bool)]) -> usize {
281 if config.is_empty() {
282 self.rgxs = Vec::new();
283 } else {
284 let map: HashMap<&str, bool> = config.iter().copied().collect();
285 self.rgxs = self
286 .master
287 .iter()
288 .filter(|rule| match map.get(rule.category.as_str()) {
289 // Listed categories follow their flag. Absent ones stay on.
290 Some(flag) => *flag,
291 None => true,
292 })
293 .cloned()
294 .collect();
295 }
296 let mut unique: Vec<&str> = Vec::new();
297 for rule in &self.rgxs {
298 if !unique.contains(&rule.category.as_str()) {
299 unique.push(rule.category.as_str());
300 }
301 }
302 self.fingerprint_codes = default_fingerprint_codes();
303 unique.len()
304 }
305
306 /// Returns the fingerprint of the tokens from the last
307 /// [`tokenize`](Self::tokenize) call.
308 ///
309 /// For each token, if its tag has a fingerprint code, that single character
310 /// is appended. Otherwise the token's literal value is appended. Tokens
311 /// tagged `punctuation` or `symbol` have no code, so their value passes
312 /// through. Before any tokenize call this returns an empty string.
313 ///
314 /// # Example
315 ///
316 /// ```
317 /// use english_tokenizer::Tokenizer;
318 /// let mut tk = Tokenizer::new();
319 /// tk.tokenize("feeling good #FunTime");
320 /// assert_eq!(tk.get_tokens_fp(), "wwh");
321 /// ```
322 pub fn get_tokens_fp(&self) -> String {
323 let mut fp = String::new();
324 for token in &self.final_tokens {
325 match self.fingerprint_codes.get(&token.tag) {
326 Some(code) if !code.is_empty() => fp.push_str(code),
327 _ => fp.push_str(&token.value),
328 }
329 }
330 fp
331 }
332
333 /// Adds a regex rule that wins over the built-ins.
334 ///
335 /// The rule is tried before every built-in rule. If `tag` is already known,
336 /// `fingerprint_code` is ignored. If `tag` is new, `fingerprint_code` must be
337 /// a non-empty string so the tag has a fingerprint. A later
338 /// [`define_config`](Self::define_config) call removes added rules.
339 ///
340 /// Write the pattern with non-capturing groups `(?:...)`. Matching takes the
341 /// whole match and the text between matches. A capturing group does not
342 /// inject its capture as an extra token.
343 ///
344 /// # Errors
345 ///
346 /// Returns [`AddError::MissingFingerprint`] when `tag` is new and the
347 /// fingerprint code is missing or empty.
348 ///
349 /// # Example
350 ///
351 /// ```
352 /// use english_tokenizer::Tokenizer;
353 /// use regex::Regex;
354 /// let mut tk = Tokenizer::new();
355 /// tk.add_regex(Regex::new(r"(?i)\(oo\)").unwrap(), "emoticon", None).unwrap();
356 /// let tokens = tk.tokenize("(oo) Hi!");
357 /// assert_eq!(tokens[0].tag, "emoticon");
358 /// ```
359 pub fn add_regex(
360 &mut self,
361 regex: Regex,
362 tag: &str,
363 fingerprint_code: Option<&str>,
364 ) -> Result<(), AddError> {
365 let known = self.has_code(tag) || self.has_category(tag);
366 // An empty code counts as no code, matching `has_code`. A new tag needs
367 // a non-empty code to register.
368 let code_present = fingerprint_code.is_some_and(|c| !c.is_empty());
369 if !known && !code_present {
370 return Err(AddError::MissingFingerprint {
371 tag: tag.to_string(),
372 });
373 }
374 if !known {
375 // Safe to unwrap: the guard above ensures a non-empty code is present.
376 self.add_tag(tag, fingerprint_code.expect("checked above"))?;
377 }
378 self.rgxs.insert(
379 0,
380 Rule {
381 regex,
382 category: tag.to_string(),
383 },
384 );
385 Ok(())
386 }
387
388 /// Registers a new tag with a fingerprint code.
389 ///
390 /// # Errors
391 ///
392 /// Returns [`AddError::MissingFingerprint`] when `fingerprint_code` is
393 /// empty. Returns [`AddError::TagExists`] when `name` already has a code.
394 pub fn add_tag(&mut self, name: &str, fingerprint_code: &str) -> Result<(), AddError> {
395 if fingerprint_code.is_empty() {
396 return Err(AddError::MissingFingerprint {
397 tag: name.to_string(),
398 });
399 }
400 if self.fingerprint_codes.contains_key(name) {
401 return Err(AddError::TagExists {
402 tag: name.to_string(),
403 });
404 }
405 self.fingerprint_codes
406 .insert(name.to_string(), fingerprint_code.to_string());
407 Ok(())
408 }
409
410 /// Whether `tag` has a non-empty fingerprint code. The source uses a truthy
411 /// check, so an empty code counts as absent.
412 fn has_code(&self, tag: &str) -> bool {
413 self.fingerprint_codes
414 .get(tag)
415 .is_some_and(|c| !c.is_empty())
416 }
417
418 fn has_category(&self, tag: &str) -> bool {
419 self.master.iter().any(|rule| rule.category == tag)
420 }
421
422 /// Recursively tokenizes `text`. Each level applies the first rule, emits its
423 /// matches as final tokens, and recurses on the gaps with the rest of the
424 /// rules. With no rules left, splits on whitespace and tags every piece
425 /// `alien`.
426 fn tokenize_recursive(&mut self, text: &str, rules: &[Rule]) {
427 if rules.is_empty() {
428 for piece in self.spaces.split(text) {
429 self.final_tokens.push(Token::new(piece, "alien"));
430 }
431 return;
432 }
433 let sentence = text.trim();
434 let units = self.tokenize_unit(sentence, &rules[0]);
435 for unit in units {
436 match unit {
437 Unit::Fragment(frag) => self.tokenize_recursive(&frag, &rules[1..]),
438 Unit::Token(token) => self.final_tokens.push(token),
439 }
440 }
441 }
442
443 /// Applies one rule to `text`. Matches become tokens. The gaps between them
444 /// become fragments to recurse on. Word matches that contain an apostrophe go
445 /// through contraction handling.
446 fn tokenize_unit(&self, text: &str, rule: &Rule) -> Vec<Unit> {
447 let mut units = Vec::new();
448 let is_word = rule.category == "word";
449 let mut last = 0;
450 for m in rule.regex.find_iter(text) {
451 let gap = &text[last..m.start()];
452 let trimmed = gap.trim();
453 if !trimmed.is_empty() {
454 units.push(Unit::Fragment(trimmed.to_string()));
455 }
456 let matched = m.as_str();
457 if is_word && self.contraction.is_match(matched) {
458 self.manage_contraction(matched, &mut units);
459 } else {
460 units.push(Unit::Token(Token::new(matched, rule.category.clone())));
461 }
462 last = m.end();
463 }
464 let tail = text[last..].trim();
465 if !tail.is_empty() {
466 units.push(Unit::Fragment(tail.to_string()));
467 }
468 units
469 }
470
471 /// Splits a word that contains an apostrophe. Tries the contraction table
472 /// first, then singular possessive, then plural possessive. Otherwise keeps
473 /// the word whole.
474 fn manage_contraction(&self, word: &str, units: &mut Vec<Unit>) {
475 if let Some(parts) = contractions::lookup(word) {
476 for part in parts {
477 units.push(Unit::Token(Token::new(*part, "word")));
478 }
479 return;
480 }
481 if let Some(caps) = self.pos_singular.captures(word) {
482 units.push(Unit::Token(Token::new(&caps[1], "word")));
483 units.push(Unit::Token(Token::new(&caps[2], "word")));
484 return;
485 }
486 if let Some(caps) = self.pos_plural.captures(word) {
487 units.push(Unit::Token(Token::new(&caps[1], "word")));
488 units.push(Unit::Token(Token::new(&caps[2], "word")));
489 return;
490 }
491 units.push(Unit::Token(Token::new(word, "word")));
492 }
493}
494
495/// Number of contraction keys in the built-in table.
496///
497/// Exposed so callers can confirm the table is complete.
498pub const CONTRACTION_COUNT: usize = contractions::COUNT;