1use std::collections::{HashMap, HashSet};
2
3static ENGLISH_STOPWORDS: &[&str] = &[
5 "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by",
6 "from", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does",
7 "did", "will", "would", "could", "should", "may", "might", "shall", "can", "not", "no", "nor",
8 "so", "yet", "both", "either", "neither", "each", "few", "more", "most", "other", "some",
9 "such", "than", "then", "too", "very", "just", "also", "as", "if", "its", "it", "this", "that",
10 "these", "those", "i", "me", "my", "we", "our", "you", "your", "he", "she", "they", "them",
11 "his", "her", "their", "what", "which", "who", "whom", "how", "when", "where", "why", "all",
12 "any", "into", "through", "during", "before", "after", "above", "below", "up", "down", "out",
13 "off", "over", "under", "again", "further", "once", "here", "there", "while", "about",
14 "against", "between", "own", "same", "only", "s", "t", "re", "ve", "ll", "d", "m",
15];
16
17#[derive(Debug, Clone)]
19pub struct TokenizerConfig {
20 pub lowercase: bool,
22 pub remove_stopwords: bool,
24 pub min_token_length: usize,
26 pub max_token_length: usize,
28 pub strip_suffixes: bool,
30}
31
32impl Default for TokenizerConfig {
33 fn default() -> Self {
34 Self {
35 lowercase: true,
36 remove_stopwords: true,
37 min_token_length: 2,
38 max_token_length: 64,
39 strip_suffixes: false,
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
47pub struct Tokenizer {
48 config: TokenizerConfig,
49 stopwords: HashSet<String>,
50}
51
52impl Tokenizer {
53 pub fn new(config: TokenizerConfig) -> Self {
55 let stopwords: HashSet<String> = ENGLISH_STOPWORDS.iter().map(|s| s.to_string()).collect();
56 Self { config, stopwords }
57 }
58
59 pub fn tokenize(&self, text: &str) -> Vec<String> {
61 self.run_pipeline(text, self.config.remove_stopwords)
62 }
63
64 pub fn tokenize_query(&self, text: &str) -> Vec<String> {
66 self.run_pipeline(text, false)
67 }
68
69 fn run_pipeline(&self, text: &str, remove_stopwords: bool) -> Vec<String> {
70 let mut tokens: Vec<String> = text
72 .split(|c: char| !c.is_alphanumeric() && c != '\'')
73 .map(|s| s.trim_matches('\''))
74 .filter(|s| !s.is_empty())
75 .map(|s| s.to_string())
76 .collect();
77
78 if self.config.lowercase {
80 tokens = tokens.into_iter().map(|t| t.to_lowercase()).collect();
81 }
82
83 tokens.retain(|t| {
85 t.len() >= self.config.min_token_length && t.len() <= self.config.max_token_length
86 });
87
88 if remove_stopwords {
90 tokens.retain(|t| !self.stopwords.contains(t.as_str()));
91 }
92
93 if self.config.strip_suffixes {
95 tokens = tokens.into_iter().map(Self::strip_suffix).collect();
96 }
97
98 tokens
99 }
100
101 fn strip_suffix(token: String) -> String {
103 const SUFFIXES: &[&str] = &["tion", "ness", "ment", "ing", "ed", "er", "es", "ly"];
105 for &suffix in SUFFIXES {
106 if token.len() > suffix.len() + 3 && token.ends_with(suffix) {
108 return token[..token.len() - suffix.len()].to_string();
109 }
110 }
111 token
112 }
113
114 pub fn compute_term_frequencies(tokens: &[String]) -> HashMap<String, usize> {
116 let mut freq: HashMap<String, usize> = HashMap::new();
117 for token in tokens {
118 *freq.entry(token.clone()).or_insert(0) += 1;
119 }
120 freq
121 }
122}
123
124impl Default for Tokenizer {
125 fn default() -> Self {
126 Self::new(TokenizerConfig::default())
127 }
128}
129
130#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn test_basic_tokenize() {
140 let tok = Tokenizer::default();
141 let tokens = tok.tokenize("The quick brown fox jumps over the lazy dog");
142 assert!(!tokens.contains(&"the".to_string()));
144 assert!(tokens.contains(&"quick".to_string()));
145 assert!(tokens.contains(&"brown".to_string()));
146 assert!(tokens.contains(&"fox".to_string()));
147 }
148
149 #[test]
150 fn test_query_keeps_stopwords() {
151 let tok = Tokenizer::default();
152 let doc_tokens = tok.tokenize("to be or not to be");
153 let query_tokens = tok.tokenize_query("to be or not to be");
154 assert!(doc_tokens.is_empty() || doc_tokens.len() < query_tokens.len());
156 assert!(query_tokens.contains(&"to".to_string()));
158 assert!(query_tokens.contains(&"be".to_string()));
159 }
160
161 #[test]
162 fn test_min_length_filter() {
163 let tok = Tokenizer::default(); let tokens = tok.tokenize("a I go running");
165 assert!(!tokens.contains(&"a".to_string()));
167 assert!(tokens.contains(&"go".to_string()));
168 assert!(tokens.contains(&"running".to_string()));
169 }
170
171 #[test]
172 fn test_compute_term_frequencies() {
173 let tokens = vec!["dog".to_string(), "cat".to_string(), "dog".to_string()];
174 let freq = Tokenizer::compute_term_frequencies(&tokens);
175 assert_eq!(freq["dog"], 2);
176 assert_eq!(freq["cat"], 1);
177 }
178
179 #[test]
180 fn test_strip_suffixes() {
181 let config = TokenizerConfig {
182 strip_suffixes: true,
183 remove_stopwords: false,
184 ..Default::default()
185 };
186 let tok = Tokenizer::new(config);
187 let tokens = tok.tokenize("running jumped faster");
188 assert!(tokens.contains(&"runn".to_string()) || tokens.contains(&"running".to_string()));
190 assert!(tokens.contains(&"jump".to_string()) || tokens.contains(&"jumped".to_string()));
191 }
192
193 #[test]
194 fn test_punctuation_split() {
195 let tok = Tokenizer::default();
196 let tokens = tok.tokenize("hello, world! foo-bar baz.qux");
197 assert!(tokens.contains(&"hello".to_string()));
198 assert!(tokens.contains(&"world".to_string()));
199 assert!(tokens.contains(&"foo".to_string()));
200 assert!(tokens.contains(&"bar".to_string()));
201 assert!(tokens.contains(&"baz".to_string()));
202 assert!(tokens.contains(&"qux".to_string()));
203 }
204}