Skip to main content

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;