Skip to main content

simi/
preprocess.rs

1//! String preprocessors: zero-copy string cleanup that normalizes unicode,
2//! handles whitespace, and removes stop-words before the math happens.
3
4use unicode_normalization::UnicodeNormalization;
5
6/// Configuration for text preprocessing.
7#[derive(Clone, Debug)]
8pub struct Preprocessor {
9    /// Whether to normalize unicode to NFC form.
10    pub normalize_unicode: bool,
11    /// Whether to collapse whitespace to single spaces.
12    pub collapse_whitespace: bool,
13    /// Whether to trim leading/trailing whitespace.
14    pub trim: bool,
15    /// Whether to convert to lowercase.
16    pub to_lowercase: bool,
17    /// Whether to remove stop words.
18    pub remove_stopwords: bool,
19    /// Custom stop words (if empty, uses built-in default set).
20    pub stopwords: Vec<String>,
21    /// Maximum string length to process (0 = unlimited).
22    pub max_length: usize,
23}
24
25impl Default for Preprocessor {
26    fn default() -> Self {
27        Self {
28            normalize_unicode: true,
29            collapse_whitespace: true,
30            trim: true,
31            to_lowercase: true,
32            remove_stopwords: false,
33            stopwords: Vec::new(),
34            max_length: 0,
35        }
36    }
37}
38
39impl Preprocessor {
40    /// Create a new preprocessor with default settings.
41    #[inline]
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Set unicode normalization.
47    #[inline]
48    pub fn with_normalize_unicode(mut self, v: bool) -> Self {
49        self.normalize_unicode = v;
50        self
51    }
52
53    /// Set whitespace collapsing.
54    #[inline]
55    pub fn with_collapse_whitespace(mut self, v: bool) -> Self {
56        self.collapse_whitespace = v;
57        self
58    }
59
60    /// Set trimming.
61    #[inline]
62    pub fn with_trim(mut self, v: bool) -> Self {
63        self.trim = v;
64        self
65    }
66
67    /// Set lowercase conversion.
68    #[inline]
69    pub fn with_lowercase(mut self, v: bool) -> Self {
70        self.to_lowercase = v;
71        self
72    }
73
74    /// Set stopword removal.
75    #[inline]
76    pub fn with_remove_stopwords(mut self, v: bool) -> Self {
77        self.remove_stopwords = v;
78        self
79    }
80
81    /// Set custom stop words.
82    #[inline]
83    pub fn with_stopwords(mut self, words: Vec<String>) -> Self {
84        self.stopwords = words;
85        self
86    }
87
88    /// Set max input length.
89    #[inline]
90    pub fn with_max_length(mut self, max: usize) -> Self {
91        self.max_length = max;
92        self
93    }
94
95    /// Preprocess a text string.
96    ///
97    /// Returns the processed string, or an error if processing fails.
98    #[inline]
99    pub fn process(&self, text: &str) -> String {
100        let mut s = text.to_string();
101
102        // Unicode normalization (NFC)
103        if self.normalize_unicode {
104            s = s.nfc().collect();
105        }
106
107        // Lowercase
108        if self.to_lowercase {
109            s = s.to_lowercase();
110        }
111
112        // Collapse whitespace
113        if self.collapse_whitespace {
114            let mut result = String::with_capacity(s.len());
115            let mut prev_was_space = false;
116            for c in s.chars() {
117                if c.is_whitespace() {
118                    if !prev_was_space {
119                        result.push(' ');
120                        prev_was_space = true;
121                    }
122                } else {
123                    result.push(c);
124                    prev_was_space = false;
125                }
126            }
127            s = result;
128        }
129
130        // Trim
131        if self.trim {
132            s = s.trim().to_string();
133        }
134
135        // Stopword removal
136        if self.remove_stopwords {
137            let result: Vec<&str> = s
138                .split_whitespace()
139                .filter(|word| !is_stopword(word, &self.stopwords))
140                .collect();
141            s = result.join(" ");
142        }
143
144        // Max length
145        if self.max_length > 0 && s.len() > self.max_length {
146            s.truncate(self.max_length);
147        }
148
149        s
150    }
151}
152
153// Default English stopword list (common words that carry little semantic weight)
154const DEFAULT_STOPWORDS: &[&str] = &[
155    "a",
156    "an",
157    "the",
158    "and",
159    "or",
160    "but",
161    "if",
162    "because",
163    "as",
164    "until",
165    "while",
166    "of",
167    "at",
168    "by",
169    "for",
170    "with",
171    "about",
172    "between",
173    "into",
174    "through",
175    "during",
176    "before",
177    "after",
178    "above",
179    "below",
180    "to",
181    "from",
182    "up",
183    "down",
184    "in",
185    "out",
186    "on",
187    "off",
188    "over",
189    "under",
190    "again",
191    "further",
192    "then",
193    "once",
194    "here",
195    "there",
196    "when",
197    "where",
198    "why",
199    "how",
200    "all",
201    "each",
202    "every",
203    "both",
204    "few",
205    "more",
206    "most",
207    "other",
208    "some",
209    "such",
210    "no",
211    "nor",
212    "not",
213    "only",
214    "own",
215    "same",
216    "so",
217    "than",
218    "too",
219    "very",
220    "just",
221    "also",
222    "am",
223    "is",
224    "are",
225    "was",
226    "were",
227    "be",
228    "been",
229    "being",
230    "have",
231    "has",
232    "had",
233    "having",
234    "do",
235    "does",
236    "did",
237    "doing",
238    "would",
239    "could",
240    "should",
241    "might",
242    "must",
243    "shall",
244    "can",
245    "will",
246    "may",
247    "need",
248    "dare",
249    "ought",
250    "used",
251    "this",
252    "that",
253    "these",
254    "those",
255    "i",
256    "me",
257    "my",
258    "myself",
259    "we",
260    "our",
261    "ours",
262    "ourselves",
263    "you",
264    "your",
265    "yours",
266    "yourself",
267    "yourselves",
268    "he",
269    "him",
270    "his",
271    "himself",
272    "she",
273    "her",
274    "hers",
275    "herself",
276    "it",
277    "its",
278    "itself",
279    "they",
280    "them",
281    "their",
282    "theirs",
283    "themselves",
284    "what",
285    "which",
286    "who",
287    "whom",
288    "whose",
289    "any",
290    "anyone",
291    "anything",
292    "anybody",
293    "everyone",
294    "everything",
295    "everybody",
296    "someone",
297    "something",
298    "somebody",
299    "nobody",
300    "nothing",
301    "none",
302    "neither",
303    "one",
304    "two",
305    "three",
306    "get",
307    "got",
308    "getting",
309    "make",
310    "made",
311    "making",
312    "take",
313    "took",
314    "taking",
315    "let",
316    "lets",
317    "letting",
318    "like",
319    "liked",
320    "likes",
321    "really",
322    "actually",
323    "basically",
324    "probably",
325    "maybe",
326    "perhaps",
327    "quite",
328    "rather",
329    "pretty",
330    "almost",
331    "nearly",
332    "hardly",
333    "scarcely",
334    "barely",
335    "already",
336    "yet",
337    "still",
338];
339
340/// Check if a word is a stopword, using custom list or the built-in default.
341#[inline]
342fn is_stopword(word: &str, custom: &[String]) -> bool {
343    if custom.is_empty() {
344        DEFAULT_STOPWORDS.contains(&word)
345    } else {
346        custom.iter().any(|w| w == word)
347    }
348}
349
350/// Convenience: preprocess text with default settings.
351#[inline]
352pub fn clean(text: &str) -> String {
353    Preprocessor::default().process(text)
354}
355
356/// Convenience: preprocess and remove stopwords.
357#[inline]
358pub fn clean_with_stopwords(text: &str) -> String {
359    Preprocessor::default()
360        .with_remove_stopwords(true)
361        .process(text)
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn default_cleanup() {
370        let cleaned = clean("  Hello   World!  ");
371        assert_eq!(cleaned, "hello world!");
372    }
373
374    #[test]
375    fn unicode_normalization() {
376        // Pre-composed vs decomposed é
377        let s = clean("\u{0065}\u{0301}"); // e + combining acute
378        assert_eq!(s, "\u{00e9}"); // NFC should collapse to é
379    }
380
381    #[test]
382    fn stopword_removal() {
383        let result = clean_with_stopwords("the quick brown fox jumps over the lazy dog");
384        // "the" and "over" should be removed
385        assert!(!result.contains("the "));
386        assert!(result.contains("quick"));
387        assert!(result.contains("fox"));
388        assert!(result.contains("jumps"));
389        assert!(result.contains("lazy"));
390        assert!(result.contains("dog"));
391    }
392
393    #[test]
394    fn lowercase() {
395        let pre = Preprocessor::default().with_lowercase(true);
396        assert_eq!(pre.process("Hello WORLD"), "hello world");
397    }
398
399    #[test]
400    fn no_lowercase() {
401        let pre = Preprocessor::default().with_lowercase(false);
402        assert_eq!(pre.process("Hello WORLD"), "Hello WORLD");
403    }
404
405    #[test]
406    fn custom_stopwords() {
407        let words = vec!["hello".to_string(), "world".to_string()];
408        let pre = Preprocessor::default()
409            .with_remove_stopwords(true)
410            .with_stopwords(words);
411        let result = pre.process("hello wonderful world");
412        assert_eq!(result, "wonderful");
413    }
414
415    #[test]
416    fn trim_input() {
417        let pre = Preprocessor::default().with_trim(true);
418        assert_eq!(pre.process("  spaced  "), "spaced");
419    }
420
421    #[test]
422    fn max_length() {
423        let pre = Preprocessor::default().with_max_length(5);
424        assert_eq!(pre.process("hello world"), "hello");
425    }
426
427    #[test]
428    fn builder_pattern() {
429        let pre = Preprocessor::new()
430            .with_normalize_unicode(true)
431            .with_collapse_whitespace(true)
432            .with_trim(true)
433            .with_lowercase(true);
434        assert_eq!(pre.process("  Hello   World  "), "hello world");
435    }
436}