1use std::collections::BTreeMap;
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16use crate::{AnalysisError, AnalysisResult};
17
18mod ascii;
19mod compiled;
20pub(crate) mod lowercase;
21mod stream;
22mod synonyms;
23pub(crate) use compiled::PreparedTokenFilter;
24use synonyms::parse_synonym_body;
25pub(crate) use synonyms::parse_synonym_body_bounded;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(tag = "type", rename_all = "snake_case")]
29pub enum TokenFilter {
30 #[cfg(feature = "nori")]
31 #[serde(rename = "nori_part_of_speech")]
32 NoriPartOfSpeech(crate::nori::NoriPOSConfig),
33 #[cfg(feature = "nori")]
34 #[serde(rename = "nori_readingform")]
35 NoriReadingForm(crate::nori::EmptyFilterConfig),
36 #[cfg(feature = "nori")]
37 #[serde(rename = "unicode_simple_lowercase")]
38 UnicodeSimpleLowercase(crate::nori::SimpleLowercaseConfig),
39 #[cfg(feature = "nori")]
40 #[serde(rename = "nori_number")]
41 NoriNumber(crate::nori::EmptyFilterConfig),
42 Lowercase,
43 Stop {
44 #[serde(default = "default_stop_language")]
45 language: String,
46 #[serde(default)]
47 custom_words: Vec<String>,
48 },
49 PorterStem,
50 #[serde(rename = "ascii_folding", alias = "a_s_c_i_i_folding")]
53 ASCIIFolding,
54 Synonym {
55 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
58 synonyms: BTreeMap<String, Vec<String>>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
64 synonyms_path: Option<PathBuf>,
65 },
66 Ngram {
67 min_gram: usize,
68 max_gram: usize,
69 #[serde(default)]
70 keep_short: bool,
71 },
72 EdgeNgram {
73 min_gram: usize,
74 max_gram: usize,
75 },
76 Length {
77 #[serde(default)]
78 min_length: usize,
79 #[serde(default)]
80 max_length: usize,
81 },
82}
83
84#[derive(Debug, thiserror::Error)]
86pub enum SynonymFileError {
87 #[error("synonym file not found: {0}")]
88 NotFound(PathBuf),
89 #[error("failed to read synonym file `{path}`: {source}")]
90 Io {
91 path: PathBuf,
92 #[source]
93 source: io::Error,
94 },
95}
96
97impl TokenFilter {
98 pub fn validate(&self) -> AnalysisResult<()> {
103 match self {
104 #[cfg(feature = "nori")]
105 TokenFilter::UnicodeSimpleLowercase(_) => self.prepare().map(|_| ()),
106 TokenFilter::Synonym {
107 synonyms_path: Some(_),
108 ..
109 }
110 | TokenFilter::Ngram { .. }
111 | TokenFilter::EdgeNgram { .. } => self.prepare().map(|_| ()),
112 _ => Ok(()),
113 }
114 }
115
116 pub fn synonym_from_path<P: AsRef<Path>>(path: P) -> Result<Self, SynonymFileError> {
122 let path = path.as_ref();
123 if !path.exists() {
124 return Err(SynonymFileError::NotFound(path.to_path_buf()));
125 }
126 read_synonym_file(path)?;
130 Ok(TokenFilter::Synonym {
131 synonyms: BTreeMap::new(),
132 synonyms_path: Some(path.to_path_buf()),
133 })
134 }
135
136 pub fn parse_synonym_file(
139 path: &Path,
140 ) -> Result<BTreeMap<String, Vec<String>>, SynonymFileError> {
141 let body = read_synonym_file(path)?;
142 Ok(parse_synonym_body(&body))
143 }
144}
145
146fn default_stop_language() -> String {
147 "english".to_string()
148}
149
150impl TokenFilter {
151 pub fn filter(&self, tokens: Vec<String>) -> AnalysisResult<Vec<String>> {
152 stream::filter(
153 &self.prepare()?,
154 crate::token::TokenBatch::from_terms(tokens),
155 )?
156 .into_terms()
157 }
158
159 pub fn filter_analyzed(
161 &self,
162 input: crate::AnalyzedText,
163 ) -> AnalysisResult<crate::AnalyzedText> {
164 self.prepare()?.filter_analyzed(input)
165 }
166
167 pub fn filter_analyzed_budgeted(
185 &self,
186 input: uqa_core::memory::Budgeted<crate::AnalyzedText>,
187 mut poll: impl FnMut() -> AnalysisResult<()>,
188 ) -> AnalysisResult<uqa_core::memory::Budgeted<crate::AnalyzedText>> {
189 poll()?;
190 self.prepare()?.filter_analyzed_budgeted(input, &mut poll)
191 }
192}
193
194fn read_synonym_file(path: &Path) -> Result<String, SynonymFileError> {
195 fs::read_to_string(path).map_err(|source| {
196 if source.kind() == io::ErrorKind::NotFound {
197 SynonymFileError::NotFound(path.to_path_buf())
198 } else {
199 SynonymFileError::Io {
200 path: path.to_path_buf(),
201 source,
202 }
203 }
204 })
205}
206
207fn validate_gram_bounds(
208 component: &'static str,
209 min_gram: usize,
210 max_gram: usize,
211) -> AnalysisResult<()> {
212 if min_gram == 0 || max_gram < min_gram {
213 return Err(AnalysisError::InvalidGramBounds {
214 component,
215 min_gram,
216 max_gram,
217 });
218 }
219 Ok(())
220}
221
222const ENGLISH_STOP_WORDS: &[&str] = &[
223 "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it",
224 "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", "there", "these",
225 "they", "this", "to", "was", "were", "will", "with", "would", "can", "could", "do", "does",
226 "did", "had", "has", "have", "he", "her", "him", "his", "how", "i", "its", "may", "me", "my",
227 "nor", "our", "own", "she", "should", "so", "some", "than", "too", "us", "very", "we", "what",
228 "when", "which", "who", "whom", "why", "you", "your",
229];
230
231pub(crate) fn builtin_stop_words(language: &str) -> &'static [&'static str] {
232 match language {
233 "english" => ENGLISH_STOP_WORDS,
234 _ => &[],
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 fn v(s: &[&str]) -> Vec<String> {
243 s.iter().map(|t| (*t).to_string()).collect()
244 }
245
246 #[test]
247 fn lowercase_lowers_each_token() {
248 let f = TokenFilter::Lowercase;
249 assert_eq!(
250 f.filter(v(&["Hello", "WORLD"])).unwrap(),
251 v(&["hello", "world"])
252 );
253 }
254
255 #[test]
256 fn stop_removes_english_stop_words() {
257 let f = TokenFilter::Stop {
258 language: "english".to_string(),
259 custom_words: vec![],
260 };
261 assert_eq!(
262 f.filter(v(&["the", "rust", "is", "fast"])).unwrap(),
263 v(&["rust", "fast"])
264 );
265 }
266
267 #[test]
268 fn stop_includes_custom_words() {
269 let f = TokenFilter::Stop {
270 language: "english".to_string(),
271 custom_words: vec!["foo".to_string()],
272 };
273 assert_eq!(f.filter(v(&["foo", "bar", "the"])).unwrap(), v(&["bar"]));
274 }
275
276 #[test]
277 fn porter_stem_runs() {
278 let f = TokenFilter::PorterStem;
279 assert_eq!(
280 f.filter(v(&["caresses", "ponies"])).unwrap(),
281 v(&["caress", "poni"])
282 );
283 }
284
285 #[test]
286 fn ascii_folding_strips_diacritics() {
287 let f = TokenFilter::ASCIIFolding;
288 assert_eq!(
289 f.filter(v(&["café", "naïve"])).unwrap(),
290 v(&["cafe", "naive"])
291 );
292 }
293
294 #[test]
295 fn ascii_folding_preserves_cjk() {
296 let f = TokenFilter::ASCIIFolding;
297 assert_eq!(f.filter(v(&["한글"])).unwrap(), v(&["한글"]));
298 }
299
300 #[test]
301 fn synonym_appends_alternatives() {
302 let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();
303 m.insert(
304 "car".to_string(),
305 vec!["auto".to_string(), "vehicle".to_string()],
306 );
307 let f = TokenFilter::Synonym {
308 synonyms: m,
309 synonyms_path: None,
310 };
311 assert_eq!(
312 f.filter(v(&["fast", "car"])).unwrap(),
313 v(&["fast", "car", "auto", "vehicle"])
314 );
315 }
316
317 #[test]
318 fn ngram_emits_substrings() {
319 let f = TokenFilter::Ngram {
320 min_gram: 2,
321 max_gram: 3,
322 keep_short: false,
323 };
324 assert_eq!(f.filter(v(&["abc"])).unwrap(), v(&["ab", "bc", "abc"]));
325 }
326
327 #[test]
328 fn ngram_drops_short_unless_keep_set() {
329 let f_drop = TokenFilter::Ngram {
330 min_gram: 3,
331 max_gram: 4,
332 keep_short: false,
333 };
334 assert!(f_drop.filter(v(&["ab"])).unwrap().is_empty());
335
336 let f_keep = TokenFilter::Ngram {
337 min_gram: 3,
338 max_gram: 4,
339 keep_short: true,
340 };
341 assert_eq!(f_keep.filter(v(&["ab"])).unwrap(), v(&["ab"]));
342 }
343
344 #[test]
345 fn edge_ngram_emits_prefixes() {
346 let f = TokenFilter::EdgeNgram {
347 min_gram: 1,
348 max_gram: 3,
349 };
350 assert_eq!(f.filter(v(&["abcd"])).unwrap(), v(&["a", "ab", "abc"]));
351 }
352
353 #[test]
354 fn length_bounds_token_size() {
355 let f = TokenFilter::Length {
356 min_length: 2,
357 max_length: 4,
358 };
359 assert_eq!(
360 f.filter(v(&["a", "ab", "abcd", "abcde"])).unwrap(),
361 v(&["ab", "abcd"])
362 );
363 }
364}