1use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use unicode_normalization::UnicodeNormalization;
16
17use crate::porter;
18use crate::{AnalysisError, AnalysisResult};
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type", rename_all = "snake_case")]
22pub enum TokenFilter {
23 Lowercase,
24 Stop {
25 #[serde(default = "default_stop_language")]
26 language: String,
27 #[serde(default)]
28 custom_words: Vec<String>,
29 },
30 PorterStem,
31 #[serde(rename = "ascii_folding", alias = "a_s_c_i_i_folding")]
34 ASCIIFolding,
35 Synonym {
36 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
39 synonyms: BTreeMap<String, Vec<String>>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
45 synonyms_path: Option<PathBuf>,
46 },
47 Ngram {
48 min_gram: usize,
49 max_gram: usize,
50 #[serde(default)]
51 keep_short: bool,
52 },
53 EdgeNgram {
54 min_gram: usize,
55 max_gram: usize,
56 },
57 Length {
58 #[serde(default)]
59 min_length: usize,
60 #[serde(default)]
61 max_length: usize,
62 },
63}
64
65#[derive(Debug, thiserror::Error)]
67pub enum SynonymFileError {
68 #[error("synonym file not found: {0}")]
69 NotFound(PathBuf),
70 #[error("failed to read synonym file `{path}`: {source}")]
71 Io {
72 path: PathBuf,
73 #[source]
74 source: io::Error,
75 },
76}
77
78impl TokenFilter {
79 pub fn validate(&self) -> AnalysisResult<()> {
84 match self {
85 TokenFilter::Synonym {
86 synonyms_path: Some(path),
87 ..
88 } => {
89 Self::parse_synonym_file(path)?;
90 Ok(())
91 }
92 TokenFilter::Ngram {
93 min_gram, max_gram, ..
94 } => validate_gram_bounds("n-gram token filter", *min_gram, *max_gram),
95 TokenFilter::EdgeNgram { min_gram, max_gram } => {
96 validate_gram_bounds("edge n-gram token filter", *min_gram, *max_gram)
97 }
98 _ => Ok(()),
99 }
100 }
101
102 pub fn synonym_from_path<P: AsRef<Path>>(path: P) -> Result<Self, SynonymFileError> {
108 let path = path.as_ref();
109 if !path.exists() {
110 return Err(SynonymFileError::NotFound(path.to_path_buf()));
111 }
112 read_synonym_file(path)?;
116 Ok(TokenFilter::Synonym {
117 synonyms: BTreeMap::new(),
118 synonyms_path: Some(path.to_path_buf()),
119 })
120 }
121
122 pub fn parse_synonym_file(
125 path: &Path,
126 ) -> Result<BTreeMap<String, Vec<String>>, SynonymFileError> {
127 let body = read_synonym_file(path)?;
128 Ok(parse_synonym_body(&body))
129 }
130}
131
132fn parse_synonym_body(body: &str) -> BTreeMap<String, Vec<String>> {
133 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
134 for raw_line in body.lines() {
135 let line = raw_line.trim();
136 if line.is_empty() || line.starts_with('#') {
137 continue;
138 }
139 if let Some((lhs, rhs)) = line.split_once("=>") {
140 let lhs_terms: Vec<String> = lhs
142 .split(',')
143 .map(str::trim)
144 .filter(|s| !s.is_empty())
145 .map(str::to_string)
146 .collect();
147 let rhs_terms: Vec<String> = rhs
148 .split(',')
149 .map(str::trim)
150 .filter(|s| !s.is_empty())
151 .map(str::to_string)
152 .collect();
153 for term in lhs_terms {
154 let entry = out.entry(term).or_default();
155 for r in &rhs_terms {
156 if !entry.iter().any(|e| e == r) {
157 entry.push(r.clone());
158 }
159 }
160 }
161 } else {
162 let members: Vec<String> = line
164 .split(',')
165 .map(str::trim)
166 .filter(|s| !s.is_empty())
167 .map(str::to_string)
168 .collect();
169 if members.len() < 2 {
170 continue;
171 }
172 for (i, term) in members.iter().enumerate() {
173 let entry = out.entry(term.clone()).or_default();
174 for (j, other) in members.iter().enumerate() {
175 if i == j {
176 continue;
177 }
178 if !entry.iter().any(|e| e == other) {
179 entry.push(other.clone());
180 }
181 }
182 }
183 }
184 }
185 out
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 let tokens = match self {
195 TokenFilter::Lowercase => tokens.into_iter().map(|t| t.to_lowercase()).collect(),
196 TokenFilter::Stop {
197 language,
198 custom_words,
199 } => {
200 let mut words: BTreeSet<&str> =
201 builtin_stop_words(language).iter().copied().collect();
202 let custom: Vec<&str> = custom_words.iter().map(String::as_str).collect();
203 words.extend(custom);
204 tokens
205 .into_iter()
206 .filter(|t| !words.contains(t.as_str()))
207 .collect()
208 }
209 TokenFilter::PorterStem => tokens.into_iter().map(|t| porter::stem(&t)).collect(),
210 TokenFilter::ASCIIFolding => tokens.into_iter().map(|t| ascii_fold(&t)).collect(),
211 TokenFilter::Synonym {
212 synonyms,
213 synonyms_path,
214 } => {
215 let resolved: BTreeMap<String, Vec<String>> = if let Some(path) = synonyms_path {
216 TokenFilter::parse_synonym_file(path)?
217 } else {
218 synonyms.clone()
219 };
220 let mut out = Vec::with_capacity(tokens.len());
221 for t in tokens {
222 if let Some(extra) = resolved.get(&t) {
223 out.push(t);
224 out.extend(extra.iter().cloned());
225 } else {
226 out.push(t);
227 }
228 }
229 out
230 }
231 TokenFilter::Ngram {
232 min_gram,
233 max_gram,
234 keep_short,
235 } => {
236 validate_gram_bounds("n-gram token filter", *min_gram, *max_gram)?;
237 let mut out = Vec::new();
238 for t in tokens {
239 let chars: Vec<char> = t.chars().collect();
240 if chars.len() < *min_gram {
241 if *keep_short {
242 out.push(t);
243 }
244 continue;
245 }
246 for n in *min_gram..=*max_gram {
247 if chars.len() < n {
248 continue;
249 }
250 for i in 0..=(chars.len() - n) {
251 out.push(chars[i..i + n].iter().collect());
252 }
253 }
254 }
255 out
256 }
257 TokenFilter::EdgeNgram { min_gram, max_gram } => {
258 validate_gram_bounds("edge n-gram token filter", *min_gram, *max_gram)?;
259 let mut out = Vec::new();
260 for t in tokens {
261 let chars: Vec<char> = t.chars().collect();
262 let upper = (*max_gram).min(chars.len());
263 for n in *min_gram..=upper {
264 out.push(chars[..n].iter().collect());
265 }
266 }
267 out
268 }
269 TokenFilter::Length {
270 min_length,
271 max_length,
272 } => tokens
273 .into_iter()
274 .filter(|t| {
275 let len = t.chars().count();
276 if len < *min_length {
277 return false;
278 }
279 if *max_length > 0 && len > *max_length {
280 return false;
281 }
282 true
283 })
284 .collect(),
285 };
286 Ok(tokens)
287 }
288}
289
290fn read_synonym_file(path: &Path) -> Result<String, SynonymFileError> {
291 fs::read_to_string(path).map_err(|source| {
292 if source.kind() == io::ErrorKind::NotFound {
293 SynonymFileError::NotFound(path.to_path_buf())
294 } else {
295 SynonymFileError::Io {
296 path: path.to_path_buf(),
297 source,
298 }
299 }
300 })
301}
302
303fn validate_gram_bounds(
304 component: &'static str,
305 min_gram: usize,
306 max_gram: usize,
307) -> AnalysisResult<()> {
308 if min_gram == 0 || max_gram < min_gram {
309 return Err(AnalysisError::InvalidGramBounds {
310 component,
311 min_gram,
312 max_gram,
313 });
314 }
315 Ok(())
316}
317
318fn ascii_fold(token: &str) -> String {
319 if token.is_ascii() {
320 return token.to_owned();
321 }
322 let mut out = String::with_capacity(token.len());
323 for ch in token.chars() {
324 if ch.is_ascii() {
325 out.push(ch);
326 continue;
327 }
328 let folded: String = ch.nfkd().filter(char::is_ascii).collect();
329 if folded.is_empty() {
330 out.push(ch);
332 } else {
333 out.push_str(&folded);
334 }
335 }
336 out
337}
338
339const ENGLISH_STOP_WORDS: &[&str] = &[
340 "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it",
341 "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", "there", "these",
342 "they", "this", "to", "was", "were", "will", "with", "would", "can", "could", "do", "does",
343 "did", "had", "has", "have", "he", "her", "him", "his", "how", "i", "its", "may", "me", "my",
344 "nor", "our", "own", "she", "should", "so", "some", "than", "too", "us", "very", "we", "what",
345 "when", "which", "who", "whom", "why", "you", "your",
346];
347
348fn builtin_stop_words(language: &str) -> &'static [&'static str] {
349 match language {
350 "english" => ENGLISH_STOP_WORDS,
351 _ => &[],
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 fn v(s: &[&str]) -> Vec<String> {
360 s.iter().map(|t| (*t).to_string()).collect()
361 }
362
363 #[test]
364 fn lowercase_lowers_each_token() {
365 let f = TokenFilter::Lowercase;
366 assert_eq!(
367 f.filter(v(&["Hello", "WORLD"])).unwrap(),
368 v(&["hello", "world"])
369 );
370 }
371
372 #[test]
373 fn stop_removes_english_stop_words() {
374 let f = TokenFilter::Stop {
375 language: "english".to_string(),
376 custom_words: vec![],
377 };
378 assert_eq!(
379 f.filter(v(&["the", "rust", "is", "fast"])).unwrap(),
380 v(&["rust", "fast"])
381 );
382 }
383
384 #[test]
385 fn stop_includes_custom_words() {
386 let f = TokenFilter::Stop {
387 language: "english".to_string(),
388 custom_words: vec!["foo".to_string()],
389 };
390 assert_eq!(f.filter(v(&["foo", "bar", "the"])).unwrap(), v(&["bar"]));
391 }
392
393 #[test]
394 fn porter_stem_runs() {
395 let f = TokenFilter::PorterStem;
396 assert_eq!(
397 f.filter(v(&["caresses", "ponies"])).unwrap(),
398 v(&["caress", "poni"])
399 );
400 }
401
402 #[test]
403 fn ascii_folding_strips_diacritics() {
404 let f = TokenFilter::ASCIIFolding;
405 assert_eq!(
406 f.filter(v(&["café", "naïve"])).unwrap(),
407 v(&["cafe", "naive"])
408 );
409 }
410
411 #[test]
412 fn ascii_folding_preserves_cjk() {
413 let f = TokenFilter::ASCIIFolding;
414 assert_eq!(f.filter(v(&["한글"])).unwrap(), v(&["한글"]));
415 }
416
417 #[test]
418 fn synonym_appends_alternatives() {
419 let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();
420 m.insert(
421 "car".to_string(),
422 vec!["auto".to_string(), "vehicle".to_string()],
423 );
424 let f = TokenFilter::Synonym {
425 synonyms: m,
426 synonyms_path: None,
427 };
428 assert_eq!(
429 f.filter(v(&["fast", "car"])).unwrap(),
430 v(&["fast", "car", "auto", "vehicle"])
431 );
432 }
433
434 #[test]
435 fn ngram_emits_substrings() {
436 let f = TokenFilter::Ngram {
437 min_gram: 2,
438 max_gram: 3,
439 keep_short: false,
440 };
441 assert_eq!(f.filter(v(&["abc"])).unwrap(), v(&["ab", "bc", "abc"]));
442 }
443
444 #[test]
445 fn ngram_drops_short_unless_keep_set() {
446 let f_drop = TokenFilter::Ngram {
447 min_gram: 3,
448 max_gram: 4,
449 keep_short: false,
450 };
451 assert!(f_drop.filter(v(&["ab"])).unwrap().is_empty());
452
453 let f_keep = TokenFilter::Ngram {
454 min_gram: 3,
455 max_gram: 4,
456 keep_short: true,
457 };
458 assert_eq!(f_keep.filter(v(&["ab"])).unwrap(), v(&["ab"]));
459 }
460
461 #[test]
462 fn edge_ngram_emits_prefixes() {
463 let f = TokenFilter::EdgeNgram {
464 min_gram: 1,
465 max_gram: 3,
466 };
467 assert_eq!(f.filter(v(&["abcd"])).unwrap(), v(&["a", "ab", "abc"]));
468 }
469
470 #[test]
471 fn length_bounds_token_size() {
472 let f = TokenFilter::Length {
473 min_length: 2,
474 max_length: 4,
475 };
476 assert_eq!(
477 f.filter(v(&["a", "ab", "abcd", "abcde"])).unwrap(),
478 v(&["ab", "abcd"])
479 );
480 }
481}