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 English stop words. Assumes input is already lowercased.
53#[derive(Debug, Default, Clone)]
54pub struct StopWordFilter;
55
56static EN_STOP_WORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
57    [
58        "a",
59        "about",
60        "above",
61        "after",
62        "again",
63        "against",
64        "all",
65        "am",
66        "an",
67        "and",
68        "any",
69        "are",
70        "as",
71        "at",
72        "be",
73        "because",
74        "been",
75        "before",
76        "being",
77        "below",
78        "between",
79        "both",
80        "but",
81        "by",
82        "can",
83        "could",
84        "did",
85        "do",
86        "does",
87        "doing",
88        "don't",
89        "down",
90        "during",
91        "each",
92        "few",
93        "for",
94        "from",
95        "further",
96        "get",
97        "got",
98        "had",
99        "has",
100        "have",
101        "having",
102        "he",
103        "her",
104        "here",
105        "hers",
106        "herself",
107        "him",
108        "himself",
109        "his",
110        "how",
111        "i",
112        "if",
113        "in",
114        "into",
115        "is",
116        "it",
117        "its",
118        "itself",
119        "just",
120        "me",
121        "more",
122        "most",
123        "my",
124        "myself",
125        "no",
126        "nor",
127        "not",
128        "now",
129        "of",
130        "off",
131        "on",
132        "once",
133        "only",
134        "or",
135        "other",
136        "our",
137        "ours",
138        "ourselves",
139        "out",
140        "over",
141        "own",
142        "same",
143        "she",
144        "should",
145        "so",
146        "some",
147        "such",
148        "than",
149        "that",
150        "the",
151        "their",
152        "theirs",
153        "them",
154        "themselves",
155        "then",
156        "there",
157        "these",
158        "they",
159        "this",
160        "those",
161        "through",
162        "to",
163        "too",
164        "under",
165        "until",
166        "up",
167        "us",
168        "very",
169        "was",
170        "we",
171        "were",
172        "what",
173        "when",
174        "where",
175        "which",
176        "while",
177        "who",
178        "whom",
179        "why",
180        "will",
181        "with",
182        "would",
183        "you",
184        "your",
185        "yours",
186        "yourself",
187        "yourselves",
188    ]
189    .into_iter()
190    .collect()
191});
192
193impl TokenFilter for StopWordFilter {
194    fn apply(&self, token: String) -> Option<String> {
195        if EN_STOP_WORDS.contains(token.as_str()) {
196            None
197        } else {
198            Some(token)
199        }
200    }
201}
202
203/// Snowball stemmer. Only stems ASCII-alphabetic tokens; others pass through.
204#[cfg(feature = "stem")]
205pub struct SnowballStemmer(rust_stemmers::Stemmer);
206
207#[cfg(feature = "stem")]
208impl SnowballStemmer {
209    /// Creates an English Snowball stemmer using the English algorithm.
210    pub fn english() -> Self {
211        Self(rust_stemmers::Stemmer::create(
212            rust_stemmers::Algorithm::English,
213        ))
214    }
215
216    /// Creates a Snowball stemmer for the specified `rust_stemmers::Algorithm`.
217    pub fn for_algorithm(algo: rust_stemmers::Algorithm) -> Self {
218        Self(rust_stemmers::Stemmer::create(algo))
219    }
220}
221
222#[cfg(feature = "stem")]
223impl TokenFilter for SnowballStemmer {
224    fn apply(&self, token: String) -> Option<String> {
225        if token.chars().all(|c| c.is_ascii_alphabetic()) {
226            Some(self.0.stem(&token).into_owned())
227        } else {
228            Some(token)
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn lowercase_ascii() {
239        assert_eq!(LowercaseFilter.apply("HELLO".into()), Some("hello".into()));
240    }
241
242    #[test]
243    fn lowercase_unicode() {
244        assert_eq!(
245            LowercaseFilter.apply("Straße".into()),
246            Some("straße".into())
247        );
248    }
249
250    #[test]
251    fn min_length_boundary() {
252        let f = MinLengthFilter(3);
253        assert_eq!(f.apply("ab".into()), None);
254        assert_eq!(f.apply("abc".into()), Some("abc".into()));
255    }
256
257    #[test]
258    fn max_length_boundary() {
259        let f = MaxLengthFilter(5);
260        assert_eq!(f.apply("hello".into()), Some("hello".into()));
261        assert_eq!(f.apply("helloo".into()), None);
262    }
263
264    #[test]
265    fn max_length_default_is_40() {
266        let f = MaxLengthFilter::default();
267        assert_eq!(f.0, 40);
268    }
269
270    #[test]
271    fn stop_word_drops() {
272        assert_eq!(StopWordFilter.apply("the".into()), None);
273        assert_eq!(StopWordFilter.apply("is".into()), None);
274        assert_eq!(
275            StopWordFilter.apply("transformer".into()),
276            Some("transformer".into())
277        );
278    }
279
280    #[test]
281    fn stop_word_case_sensitive() {
282        // StopWordFilter expects lowercased input
283        assert_eq!(StopWordFilter.apply("The".into()), Some("The".into()));
284    }
285}