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;
23#[cfg(any(feature = "nori", feature = "kuromoji"))]
24mod unicode;
25pub(crate) use compiled::PreparedTokenFilter;
26use synonyms::parse_synonym_body;
27pub(crate) use synonyms::parse_synonym_body_bounded;
28
29#[cfg(any(feature = "nori", feature = "kuromoji"))]
31#[derive(Debug, Clone, Default, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct EmptyFilterConfig {}
34#[cfg(any(feature = "nori", feature = "kuromoji"))]
35pub use unicode::{SimpleLowercaseConfig, UnicodeProfileSource};
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum TokenFilter {
40 #[cfg(feature = "kuromoji")]
41 #[serde(rename = "kuromoji_part_of_speech")]
42 KuromojiPartOfSpeech(crate::kuromoji::KuromojiPOSConfig),
43 #[cfg(feature = "kuromoji")]
44 #[serde(rename = "kuromoji_stop")]
45 KuromojiStop(crate::kuromoji::KuromojiStopConfig),
46 #[cfg(feature = "kuromoji")]
47 #[serde(rename = "kuromoji_completion")]
48 KuromojiCompletion(crate::kuromoji::KuromojiCompletionConfig),
49 #[cfg(feature = "kuromoji")]
50 #[serde(rename = "kuromoji_baseform")]
51 KuromojiBaseForm(EmptyFilterConfig),
52 #[cfg(feature = "kuromoji")]
53 #[serde(rename = "kuromoji_stemmer")]
54 KuromojiStem(crate::kuromoji::KuromojiStemConfig),
55 #[cfg(feature = "kuromoji")]
56 #[serde(rename = "kuromoji_hiragana_uppercase")]
57 KuromojiHiraganaUppercase(EmptyFilterConfig),
58 #[cfg(feature = "kuromoji")]
59 #[serde(rename = "kuromoji_katakana_uppercase")]
60 KuromojiKatakanaUppercase(EmptyFilterConfig),
61 #[cfg(feature = "kuromoji")]
62 #[serde(rename = "kuromoji_readingform")]
63 KuromojiReadingForm(crate::kuromoji::KuromojiReadingFormConfig),
64 #[cfg(feature = "kuromoji")]
65 #[serde(rename = "kuromoji_number")]
66 KuromojiNumber(EmptyFilterConfig),
67 #[cfg(feature = "nori")]
68 #[serde(rename = "nori_part_of_speech")]
69 NoriPartOfSpeech(crate::nori::NoriPOSConfig),
70 #[cfg(feature = "nori")]
71 #[serde(rename = "nori_readingform")]
72 NoriReadingForm(crate::nori::EmptyFilterConfig),
73 #[cfg(any(feature = "nori", feature = "kuromoji"))]
74 #[serde(rename = "unicode_simple_lowercase")]
75 UnicodeSimpleLowercase(SimpleLowercaseConfig),
76 #[cfg(feature = "nori")]
77 #[serde(rename = "nori_number")]
78 NoriNumber(crate::nori::EmptyFilterConfig),
79 Lowercase,
80 Stop {
81 #[serde(default = "default_stop_language")]
82 language: String,
83 #[serde(default)]
84 custom_words: Vec<String>,
85 },
86 PorterStem,
87 #[serde(rename = "ascii_folding", alias = "a_s_c_i_i_folding")]
90 ASCIIFolding,
91 Synonym {
92 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
95 synonyms: BTreeMap<String, Vec<String>>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
101 synonyms_path: Option<PathBuf>,
102 },
103 Ngram {
104 min_gram: usize,
105 max_gram: usize,
106 #[serde(default)]
107 keep_short: bool,
108 },
109 EdgeNgram {
110 min_gram: usize,
111 max_gram: usize,
112 },
113 Length {
114 #[serde(default)]
115 min_length: usize,
116 #[serde(default)]
117 max_length: usize,
118 },
119}
120
121#[derive(Debug, thiserror::Error)]
123pub enum SynonymFileError {
124 #[error("synonym file not found: {0}")]
125 NotFound(PathBuf),
126 #[error("failed to read synonym file `{path}`: {source}")]
127 Io {
128 path: PathBuf,
129 #[source]
130 source: io::Error,
131 },
132}
133
134impl TokenFilter {
135 pub fn validate(&self) -> AnalysisResult<()> {
140 match self {
141 #[cfg(feature = "kuromoji")]
142 TokenFilter::KuromojiStem(_)
143 | TokenFilter::KuromojiPartOfSpeech(_)
144 | TokenFilter::KuromojiStop(_)
145 | TokenFilter::KuromojiCompletion(_) => self.prepare().map(|_| ()),
146 #[cfg(any(feature = "nori", feature = "kuromoji"))]
147 TokenFilter::UnicodeSimpleLowercase(_) => self.prepare().map(|_| ()),
148 TokenFilter::Synonym {
149 synonyms_path: Some(_),
150 ..
151 }
152 | TokenFilter::Ngram { .. }
153 | TokenFilter::EdgeNgram { .. } => self.prepare().map(|_| ()),
154 _ => Ok(()),
155 }
156 }
157
158 pub fn synonym_from_path<P: AsRef<Path>>(path: P) -> Result<Self, SynonymFileError> {
164 let path = path.as_ref();
165 if !path.exists() {
166 return Err(SynonymFileError::NotFound(path.to_path_buf()));
167 }
168 read_synonym_file(path)?;
172 Ok(TokenFilter::Synonym {
173 synonyms: BTreeMap::new(),
174 synonyms_path: Some(path.to_path_buf()),
175 })
176 }
177
178 pub fn parse_synonym_file(
181 path: &Path,
182 ) -> Result<BTreeMap<String, Vec<String>>, SynonymFileError> {
183 let body = read_synonym_file(path)?;
184 Ok(parse_synonym_body(&body))
185 }
186}
187
188fn default_stop_language() -> String {
189 "english".to_string()
190}
191
192impl TokenFilter {
193 pub fn filter(&self, tokens: Vec<String>) -> AnalysisResult<Vec<String>> {
194 stream::filter(
195 &self.prepare()?,
196 crate::token::TokenBatch::from_terms(tokens),
197 )?
198 .into_terms()
199 }
200
201 pub fn filter_analyzed(
203 &self,
204 input: crate::AnalyzedText,
205 ) -> AnalysisResult<crate::AnalyzedText> {
206 let output = self.prepare()?.filter_analyzed(input)?;
207 #[cfg(feature = "kuromoji")]
208 output.validate_japanese_attributes(&mut || Ok(()))?;
209 Ok(output)
210 }
211
212 pub fn filter_analyzed_budgeted(
230 &self,
231 input: uqa_core::memory::Budgeted<crate::AnalyzedText>,
232 mut poll: impl FnMut() -> AnalysisResult<()>,
233 ) -> AnalysisResult<uqa_core::memory::Budgeted<crate::AnalyzedText>> {
234 poll()?;
235 let output = self.prepare()?.filter_analyzed_budgeted(input, &mut poll)?;
236 #[cfg(feature = "kuromoji")]
237 output.validate_japanese_attributes(&mut poll)?;
238 Ok(output)
239 }
240}
241
242fn read_synonym_file(path: &Path) -> Result<String, SynonymFileError> {
243 fs::read_to_string(path).map_err(|source| {
244 if source.kind() == io::ErrorKind::NotFound {
245 SynonymFileError::NotFound(path.to_path_buf())
246 } else {
247 SynonymFileError::Io {
248 path: path.to_path_buf(),
249 source,
250 }
251 }
252 })
253}
254
255fn validate_gram_bounds(
256 component: &'static str,
257 min_gram: usize,
258 max_gram: usize,
259) -> AnalysisResult<()> {
260 if min_gram == 0 || max_gram < min_gram {
261 return Err(AnalysisError::InvalidGramBounds {
262 component,
263 min_gram,
264 max_gram,
265 });
266 }
267 Ok(())
268}
269
270const ENGLISH_STOP_WORDS: &[&str] = &[
271 "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it",
272 "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", "there", "these",
273 "they", "this", "to", "was", "were", "will", "with", "would", "can", "could", "do", "does",
274 "did", "had", "has", "have", "he", "her", "him", "his", "how", "i", "its", "may", "me", "my",
275 "nor", "our", "own", "she", "should", "so", "some", "than", "too", "us", "very", "we", "what",
276 "when", "which", "who", "whom", "why", "you", "your",
277];
278
279pub(crate) fn builtin_stop_words(language: &str) -> &'static [&'static str] {
280 match language {
281 "english" => ENGLISH_STOP_WORDS,
282 _ => &[],
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 fn v(s: &[&str]) -> Vec<String> {
291 s.iter().map(|t| (*t).to_string()).collect()
292 }
293
294 #[test]
295 fn lowercase_lowers_each_token() {
296 let f = TokenFilter::Lowercase;
297 assert_eq!(
298 f.filter(v(&["Hello", "WORLD"])).unwrap(),
299 v(&["hello", "world"])
300 );
301 }
302
303 #[test]
304 fn stop_removes_english_stop_words() {
305 let f = TokenFilter::Stop {
306 language: "english".to_string(),
307 custom_words: vec![],
308 };
309 assert_eq!(
310 f.filter(v(&["the", "rust", "is", "fast"])).unwrap(),
311 v(&["rust", "fast"])
312 );
313 }
314
315 #[test]
316 fn stop_includes_custom_words() {
317 let f = TokenFilter::Stop {
318 language: "english".to_string(),
319 custom_words: vec!["foo".to_string()],
320 };
321 assert_eq!(f.filter(v(&["foo", "bar", "the"])).unwrap(), v(&["bar"]));
322 }
323
324 #[test]
325 fn porter_stem_runs() {
326 let f = TokenFilter::PorterStem;
327 assert_eq!(
328 f.filter(v(&["caresses", "ponies"])).unwrap(),
329 v(&["caress", "poni"])
330 );
331 }
332
333 #[test]
334 fn ascii_folding_strips_diacritics() {
335 let f = TokenFilter::ASCIIFolding;
336 assert_eq!(
337 f.filter(v(&["café", "naïve"])).unwrap(),
338 v(&["cafe", "naive"])
339 );
340 }
341
342 #[test]
343 fn ascii_folding_preserves_cjk() {
344 let f = TokenFilter::ASCIIFolding;
345 assert_eq!(f.filter(v(&["한글"])).unwrap(), v(&["한글"]));
346 }
347
348 #[test]
349 fn synonym_appends_alternatives() {
350 let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();
351 m.insert(
352 "car".to_string(),
353 vec!["auto".to_string(), "vehicle".to_string()],
354 );
355 let f = TokenFilter::Synonym {
356 synonyms: m,
357 synonyms_path: None,
358 };
359 assert_eq!(
360 f.filter(v(&["fast", "car"])).unwrap(),
361 v(&["fast", "car", "auto", "vehicle"])
362 );
363 }
364
365 #[test]
366 fn ngram_emits_substrings() {
367 let f = TokenFilter::Ngram {
368 min_gram: 2,
369 max_gram: 3,
370 keep_short: false,
371 };
372 assert_eq!(f.filter(v(&["abc"])).unwrap(), v(&["ab", "bc", "abc"]));
373 }
374
375 #[test]
376 fn ngram_drops_short_unless_keep_set() {
377 let f_drop = TokenFilter::Ngram {
378 min_gram: 3,
379 max_gram: 4,
380 keep_short: false,
381 };
382 assert!(f_drop.filter(v(&["ab"])).unwrap().is_empty());
383
384 let f_keep = TokenFilter::Ngram {
385 min_gram: 3,
386 max_gram: 4,
387 keep_short: true,
388 };
389 assert_eq!(f_keep.filter(v(&["ab"])).unwrap(), v(&["ab"]));
390 }
391
392 #[test]
393 fn edge_ngram_emits_prefixes() {
394 let f = TokenFilter::EdgeNgram {
395 min_gram: 1,
396 max_gram: 3,
397 };
398 assert_eq!(f.filter(v(&["abcd"])).unwrap(), v(&["a", "ab", "abc"]));
399 }
400
401 #[test]
402 fn length_bounds_token_size() {
403 let f = TokenFilter::Length {
404 min_length: 2,
405 max_length: 4,
406 };
407 assert_eq!(
408 f.filter(v(&["a", "ab", "abcd", "abcde"])).unwrap(),
409 v(&["ab", "abcd"])
410 );
411 }
412}