Skip to main content

khive_text/
filter.rs

1//! Token filters: lowercase, stop words, length constraints, stemming.
2
3use std::collections::HashSet;
4use std::sync::LazyLock;
5
6use crate::TokenFilter;
7
8/// Lowercases tokens (Unicode-aware).
9#[derive(Debug, Default, Clone)]
10pub struct LowercaseFilter;
11
12impl TokenFilter for LowercaseFilter {
13    fn apply(&self, token: String) -> Option<String> {
14        Some(token.to_lowercase())
15    }
16}
17
18/// Drops tokens shorter than `min` characters.
19#[derive(Debug, Clone)]
20pub struct MinLengthFilter(pub usize);
21
22impl TokenFilter for MinLengthFilter {
23    fn apply(&self, token: String) -> Option<String> {
24        if token.chars().count() >= self.0 {
25            Some(token)
26        } else {
27            None
28        }
29    }
30}
31
32/// Drops tokens longer than `max` characters.
33#[derive(Debug, Clone)]
34pub struct MaxLengthFilter(pub usize);
35
36impl Default for MaxLengthFilter {
37    fn default() -> Self {
38        Self(40)
39    }
40}
41
42impl TokenFilter for MaxLengthFilter {
43    fn apply(&self, token: String) -> Option<String> {
44        if token.chars().count() <= self.0 {
45            Some(token)
46        } else {
47            None
48        }
49    }
50}
51
52/// Drops the general English stop-word set; input must already be lowercase.
53///
54/// See `crates/khive-text/docs/api/filters.md`.
55#[derive(Debug, Default, Clone)]
56pub struct StopWordFilter;
57
58static EN_STOP_WORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
59    [
60        "a",
61        "about",
62        "above",
63        "after",
64        "again",
65        "against",
66        "all",
67        "am",
68        "an",
69        "and",
70        "any",
71        "are",
72        "as",
73        "at",
74        "be",
75        "because",
76        "been",
77        "before",
78        "being",
79        "below",
80        "between",
81        "both",
82        "but",
83        "by",
84        "can",
85        "could",
86        "did",
87        "do",
88        "does",
89        "doing",
90        "don't",
91        "down",
92        "during",
93        "each",
94        "few",
95        "for",
96        "from",
97        "further",
98        "get",
99        "got",
100        "had",
101        "has",
102        "have",
103        "having",
104        "he",
105        "her",
106        "here",
107        "hers",
108        "herself",
109        "him",
110        "himself",
111        "his",
112        "how",
113        "i",
114        "if",
115        "in",
116        "into",
117        "is",
118        "it",
119        "its",
120        "itself",
121        "just",
122        "me",
123        "more",
124        "most",
125        "my",
126        "myself",
127        "no",
128        "nor",
129        "not",
130        "now",
131        "of",
132        "off",
133        "on",
134        "once",
135        "only",
136        "or",
137        "other",
138        "our",
139        "ours",
140        "ourselves",
141        "out",
142        "over",
143        "own",
144        "same",
145        "she",
146        "should",
147        "so",
148        "some",
149        "such",
150        "than",
151        "that",
152        "the",
153        "their",
154        "theirs",
155        "them",
156        "themselves",
157        "then",
158        "there",
159        "these",
160        "they",
161        "this",
162        "those",
163        "through",
164        "to",
165        "too",
166        "under",
167        "until",
168        "up",
169        "us",
170        "very",
171        "was",
172        "we",
173        "were",
174        "what",
175        "when",
176        "where",
177        "which",
178        "while",
179        "who",
180        "whom",
181        "why",
182        "will",
183        "with",
184        "would",
185        "you",
186        "your",
187        "yours",
188        "yourself",
189        "yourselves",
190    ]
191    .into_iter()
192    .collect()
193});
194
195impl TokenFilter for StopWordFilter {
196    fn apply(&self, token: String) -> Option<String> {
197        if EN_STOP_WORDS.contains(token.as_str()) {
198            None
199        } else {
200            Some(token)
201        }
202    }
203}
204
205/// Drops the same stop words as `khive-bm25`'s `SimpleTokenizer::default()`.
206/// Scoped to `preset::standard()` so the public [`StopWordFilter`] stop list
207/// is unaffected. Assumes input is already lowercased.
208#[derive(Debug, Default, Clone)]
209pub struct Bm25StopWordFilter;
210
211static BM25_STOP_WORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
212    [
213        "a", "an", "and", "are", "as", "at", "be", "been", "being", "but", "by", "can", "did",
214        "do", "does", "doing", "done", "for", "from", "had", "has", "have", "having", "he", "her",
215        "here", "hers", "him", "his", "how", "i", "if", "in", "into", "is", "it", "its", "just",
216        "may", "me", "might", "my", "no", "nor", "not", "of", "on", "or", "our", "out", "own",
217        "say", "she", "should", "so", "some", "such", "than", "that", "the", "their", "them",
218        "then", "there", "these", "they", "this", "those", "through", "to", "too", "up", "us",
219        "very", "was", "we", "were", "what", "when", "where", "which", "while", "who", "whom",
220        "why", "will", "with", "would", "you", "your",
221    ]
222    .into_iter()
223    .collect()
224});
225
226impl TokenFilter for Bm25StopWordFilter {
227    fn apply(&self, token: String) -> Option<String> {
228        if BM25_STOP_WORDS.contains(token.as_str()) {
229            None
230        } else {
231            Some(token)
232        }
233    }
234}
235
236/// Snowball stemmer for ASCII-alphabetic tokens; all other tokens pass through.
237///
238/// See `crates/khive-text/docs/api/filters.md`.
239#[cfg(feature = "stem")]
240pub struct SnowballStemmer(rust_stemmers::Stemmer);
241
242#[cfg(feature = "stem")]
243impl SnowballStemmer {
244    /// Creates an English Snowball stemmer using the English algorithm.
245    pub fn english() -> Self {
246        Self(rust_stemmers::Stemmer::create(
247            rust_stemmers::Algorithm::English,
248        ))
249    }
250
251    /// Creates a Snowball stemmer for the specified `rust_stemmers::Algorithm`.
252    pub fn for_algorithm(algo: rust_stemmers::Algorithm) -> Self {
253        Self(rust_stemmers::Stemmer::create(algo))
254    }
255}
256
257#[cfg(feature = "stem")]
258impl TokenFilter for SnowballStemmer {
259    fn apply(&self, token: String) -> Option<String> {
260        if token.chars().all(|c| c.is_ascii_alphabetic()) {
261            Some(self.0.stem(&token).into_owned())
262        } else {
263            Some(token)
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn lowercase_ascii() {
274        assert_eq!(LowercaseFilter.apply("HELLO".into()), Some("hello".into()));
275    }
276
277    #[test]
278    fn lowercase_unicode() {
279        assert_eq!(
280            LowercaseFilter.apply("Straße".into()),
281            Some("straße".into())
282        );
283    }
284
285    #[test]
286    fn min_length_boundary() {
287        let f = MinLengthFilter(3);
288        assert_eq!(f.apply("ab".into()), None);
289        assert_eq!(f.apply("abc".into()), Some("abc".into()));
290    }
291
292    #[test]
293    fn max_length_boundary() {
294        let f = MaxLengthFilter(5);
295        assert_eq!(f.apply("hello".into()), Some("hello".into()));
296        assert_eq!(f.apply("helloo".into()), None);
297    }
298
299    #[test]
300    fn max_length_default_is_40() {
301        let f = MaxLengthFilter::default();
302        assert_eq!(f.0, 40);
303    }
304
305    #[test]
306    fn stop_word_drops() {
307        assert_eq!(StopWordFilter.apply("the".into()), None);
308        assert_eq!(StopWordFilter.apply("is".into()), None);
309        assert_eq!(
310            StopWordFilter.apply("transformer".into()),
311            Some("transformer".into())
312        );
313    }
314
315    #[test]
316    fn stop_word_case_sensitive() {
317        // StopWordFilter expects lowercased input
318        assert_eq!(StopWordFilter.apply("The".into()), Some("The".into()));
319    }
320}