1use std::collections::HashMap;
15
16use rust_stemmers::{Algorithm, Stemmer};
17use unicode_normalization::UnicodeNormalization;
18
19pub trait TextAnalyzer: Send + Sync {
24 fn analyze(&self, text: &str) -> Vec<String>;
26
27 fn name(&self) -> &str;
29}
30
31pub struct StandardAnalyzer;
35
36impl TextAnalyzer for StandardAnalyzer {
37 fn analyze(&self, text: &str) -> Vec<String> {
38 analyze(text)
39 }
40
41 fn name(&self) -> &str {
42 "standard"
43 }
44}
45
46pub struct SimpleAnalyzer;
50
51impl TextAnalyzer for SimpleAnalyzer {
52 fn analyze(&self, text: &str) -> Vec<String> {
53 text.to_lowercase()
54 .split_whitespace()
55 .filter(|w| w.len() > 1)
56 .map(|w| w.to_string())
57 .collect()
58 }
59
60 fn name(&self) -> &str {
61 "simple"
62 }
63}
64
65pub struct KeywordAnalyzer;
70
71impl TextAnalyzer for KeywordAnalyzer {
72 fn analyze(&self, text: &str) -> Vec<String> {
73 let trimmed = text.trim().to_lowercase();
74 if trimmed.is_empty() {
75 Vec::new()
76 } else {
77 vec![trimmed]
78 }
79 }
80
81 fn name(&self) -> &str {
82 "keyword"
83 }
84}
85
86pub struct LanguageAnalyzer {
88 algorithm: Algorithm,
89 lang_name: String,
90}
91
92impl LanguageAnalyzer {
93 pub fn new(language: &str) -> Option<Self> {
94 let algorithm = match language.to_lowercase().as_str() {
95 "english" | "en" => Algorithm::English,
96 "german" | "de" => Algorithm::German,
97 "french" | "fr" => Algorithm::French,
98 "spanish" | "es" => Algorithm::Spanish,
99 "italian" | "it" => Algorithm::Italian,
100 "portuguese" | "pt" => Algorithm::Portuguese,
101 "dutch" | "nl" => Algorithm::Dutch,
102 "swedish" | "sv" => Algorithm::Swedish,
103 "norwegian" | "no" => Algorithm::Norwegian,
104 "danish" | "da" => Algorithm::Danish,
105 "finnish" | "fi" => Algorithm::Finnish,
106 "russian" | "ru" => Algorithm::Russian,
107 "turkish" | "tr" => Algorithm::Turkish,
108 "hungarian" | "hu" => Algorithm::Hungarian,
109 "romanian" | "ro" => Algorithm::Romanian,
110 _ => return None,
111 };
112 Some(Self {
113 algorithm,
114 lang_name: language.to_lowercase(),
115 })
116 }
117}
118
119impl TextAnalyzer for LanguageAnalyzer {
120 fn analyze(&self, text: &str) -> Vec<String> {
121 let stemmer = Stemmer::create(self.algorithm);
122 tokenize_with_stemmer(text, &stemmer)
123 }
124
125 fn name(&self) -> &str {
126 &self.lang_name
127 }
128}
129
130pub struct NgramAnalyzer {
135 min: usize,
136 max: usize,
137}
138
139impl NgramAnalyzer {
140 pub fn new(min: usize, max: usize) -> Self {
141 Self {
142 min: min.max(1),
143 max: max.max(min.max(1)),
144 }
145 }
146}
147
148impl TextAnalyzer for NgramAnalyzer {
149 fn analyze(&self, text: &str) -> Vec<String> {
150 let lower = text.to_lowercase();
151 let mut ngrams = Vec::new();
152 for word in lower.split(|c: char| !c.is_alphanumeric()) {
153 if word.is_empty() {
154 continue;
155 }
156 let chars: Vec<char> = word.chars().collect();
157 for n in self.min..=self.max {
158 if n > chars.len() {
159 break;
160 }
161 for window in chars.windows(n) {
162 ngrams.push(window.iter().collect());
163 }
164 }
165 }
166 ngrams
167 }
168
169 fn name(&self) -> &str {
170 "ngram"
171 }
172}
173
174pub struct EdgeNgramAnalyzer {
179 min: usize,
180 max: usize,
181}
182
183impl EdgeNgramAnalyzer {
184 pub fn new(min: usize, max: usize) -> Self {
185 Self {
186 min: min.max(1),
187 max: max.max(min.max(1)),
188 }
189 }
190}
191
192impl TextAnalyzer for EdgeNgramAnalyzer {
193 fn analyze(&self, text: &str) -> Vec<String> {
194 let lower = text.to_lowercase();
195 let mut ngrams = Vec::new();
196 for word in lower.split(|c: char| !c.is_alphanumeric()) {
197 if word.is_empty() {
198 continue;
199 }
200 let chars: Vec<char> = word.chars().collect();
201 for n in self.min..=self.max.min(chars.len()) {
202 ngrams.push(chars[..n].iter().collect());
203 }
204 }
205 ngrams
206 }
207
208 fn name(&self) -> &str {
209 "edge_ngram"
210 }
211}
212
213pub struct SynonymMap {
227 entries: HashMap<String, Vec<String>>,
229}
230
231impl SynonymMap {
232 pub fn new() -> Self {
233 Self {
234 entries: HashMap::new(),
235 }
236 }
237
238 pub fn add(&mut self, term: &str, synonyms: &[&str]) {
240 let key = term.to_lowercase();
241 let vals: Vec<String> = synonyms.iter().map(|s| s.to_lowercase()).collect();
242 self.entries.insert(key, vals);
243 }
244
245 pub fn expand(&self, tokens: &[String]) -> Vec<String> {
251 let mut expanded = Vec::with_capacity(tokens.len() * 2);
252 for token in tokens {
253 expanded.push(token.clone());
254 if let Some(synonyms) = self.entries.get(token.as_str()) {
255 expanded.extend(synonyms.iter().cloned());
256 }
257 }
258 expanded
259 }
260
261 pub fn len(&self) -> usize {
263 self.entries.len()
264 }
265
266 pub fn is_empty(&self) -> bool {
268 self.entries.is_empty()
269 }
270}
271
272impl Default for SynonymMap {
273 fn default() -> Self {
274 Self::new()
275 }
276}
277
278pub struct AnalyzerRegistry {
288 overrides: HashMap<String, Box<dyn TextAnalyzer>>,
290 synonyms: HashMap<String, SynonymMap>,
292}
293
294impl AnalyzerRegistry {
295 pub fn new() -> Self {
296 Self {
297 overrides: HashMap::new(),
298 synonyms: HashMap::new(),
299 }
300 }
301
302 pub fn set_analyzer(&mut self, collection: &str, analyzer_name: &str) -> bool {
313 let analyzer: Box<dyn TextAnalyzer> = match analyzer_name {
314 "standard" => Box::new(StandardAnalyzer),
315 "simple" => Box::new(SimpleAnalyzer),
316 "keyword" => Box::new(KeywordAnalyzer),
317 "ngram" => Box::new(NgramAnalyzer::new(3, 4)),
318 "edge_ngram" => Box::new(EdgeNgramAnalyzer::new(2, 5)),
319 name if name.starts_with("ngram:") => {
320 let parts: Vec<&str> = name.splitn(3, ':').collect();
321 let min = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(3);
322 let max = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(4);
323 Box::new(NgramAnalyzer::new(min, max))
324 }
325 name if name.starts_with("edge_ngram:") => {
326 let parts: Vec<&str> = name.splitn(3, ':').collect();
327 let min = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(2);
328 let max = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
329 Box::new(EdgeNgramAnalyzer::new(min, max))
330 }
331 lang => match LanguageAnalyzer::new(lang) {
332 Some(a) => Box::new(a),
333 None => return false,
334 },
335 };
336 self.overrides.insert(collection.to_string(), analyzer);
337 true
338 }
339
340 pub fn add_synonym(&mut self, collection: &str, term: &str, synonyms: &[&str]) {
342 self.synonyms
343 .entry(collection.to_string())
344 .or_default()
345 .add(term, synonyms);
346 }
347
348 pub fn get_synonyms(&self, collection: &str) -> Option<&SynonymMap> {
350 self.synonyms.get(collection)
351 }
352
353 pub fn analyze(&self, collection: &str, text: &str) -> Vec<String> {
358 let tokens = match self.overrides.get(collection) {
359 Some(analyzer) => analyzer.analyze(text),
360 None => analyze(text),
361 };
362 match self.synonyms.get(collection) {
364 Some(syn_map) if !syn_map.is_empty() => syn_map.expand(&tokens),
365 _ => tokens,
366 }
367 }
368
369 pub fn analyze_for_index(&self, collection: &str, text: &str) -> Vec<String> {
371 match self.overrides.get(collection) {
372 Some(analyzer) => analyzer.analyze(text),
373 None => analyze(text),
374 }
375 }
376}
377
378impl Default for AnalyzerRegistry {
379 fn default() -> Self {
380 Self::new()
381 }
382}
383
384pub fn analyze(text: &str) -> Vec<String> {
389 let stemmer = Stemmer::create(Algorithm::English);
390 tokenize_with_stemmer(text, &stemmer)
391}
392
393fn tokenize_with_stemmer(text: &str, stemmer: &Stemmer) -> Vec<String> {
397 let normalized: String = text
399 .nfd()
400 .filter(|c| !c.is_ascii() || !unicode_normalization::char::is_combining_mark(*c))
401 .flat_map(char::to_lowercase)
402 .collect();
403
404 let mut tokens = Vec::new();
405
406 for word in normalized.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') {
409 let trimmed = word.trim_matches(|c: char| c == '-' || c == '_');
410 if trimmed.is_empty() {
411 continue;
412 }
413
414 if trimmed.len() <= 1 {
416 continue;
417 }
418
419 if is_stop_word(trimmed) {
421 continue;
422 }
423
424 let stemmed = stemmer.stem(trimmed);
426 if !stemmed.is_empty() {
427 tokens.push(stemmed.into_owned());
428 }
429 }
430
431 tokens
432}
433
434fn is_stop_word(word: &str) -> bool {
438 STOP_WORDS.binary_search(&word).is_ok()
439}
440
441static STOP_WORDS: &[&str] = &[
443 "a", "about", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "do", "for",
444 "from", "had", "has", "have", "he", "her", "him", "his", "how", "if", "in", "into", "is", "it",
445 "its", "just", "me", "my", "no", "not", "of", "on", "or", "our", "out", "own", "say", "she",
446 "so", "some", "than", "that", "the", "their", "them", "then", "there", "these", "they", "this",
447 "to", "too", "up", "us", "very", "was", "we", "were", "what", "when", "which", "who", "will",
448 "with", "would", "you", "your",
449];
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn basic_analysis() {
457 let tokens = analyze("The quick Brown FOX jumped over the lazy dog");
458 assert!(tokens.contains(&"quick".to_string()));
460 assert!(tokens.contains(&"brown".to_string()));
461 assert!(tokens.contains(&"fox".to_string()));
462 assert!(tokens.contains(&"jump".to_string())); assert!(tokens.contains(&"lazi".to_string())); assert!(tokens.contains(&"dog".to_string()));
465 assert!(!tokens.contains(&"the".to_string())); }
467
468 #[test]
469 fn stop_words_removed() {
470 let tokens = analyze("this is a test of the system");
471 assert_eq!(tokens, vec!["test", "system"]);
473 }
474
475 #[test]
476 fn stemming_works() {
477 let tokens = analyze("running databases distributed systems");
478 assert!(tokens.contains(&"run".to_string()));
479 assert!(tokens.contains(&"databas".to_string()));
480 assert!(tokens.contains(&"distribut".to_string()));
481 assert!(tokens.contains(&"system".to_string()));
482 }
483
484 #[test]
485 fn unicode_normalization() {
486 let tokens = analyze("cafe\u{0301}"); assert_eq!(tokens, vec!["cafe"]); }
489
490 #[test]
491 fn hyphenated_words_preserved() {
492 let tokens = analyze("e-mail real-time");
493 assert!(tokens.contains(&"e-mail".to_string()) || tokens.contains(&"email".to_string()));
494 assert!(
495 tokens.contains(&"real-tim".to_string()) || tokens.contains(&"real-time".to_string())
496 );
497 }
498
499 #[test]
500 fn empty_and_single_char_filtered() {
501 let tokens = analyze("I a x ");
502 assert!(tokens.is_empty());
503 }
504
505 #[test]
506 fn synonym_expansion() {
507 let mut syn = SynonymMap::new();
508 syn.add("db", &["databas", "rdbms"]);
509 let tokens = vec!["db".to_string(), "query".to_string()];
510 let expanded = syn.expand(&tokens);
511 assert_eq!(expanded.len(), 4); assert!(expanded.contains(&"databas".to_string()));
513 assert!(expanded.contains(&"rdbms".to_string()));
514 }
515
516 #[test]
517 fn analyzer_registry_with_synonyms() {
518 let mut registry = AnalyzerRegistry::new();
519 registry.add_synonym("docs", "db", &["databas"]);
520
521 let tokens = registry.analyze("docs", "db query");
523 assert!(tokens.contains(&"databas".to_string()));
524
525 let index_tokens = registry.analyze_for_index("docs", "db query");
527 assert!(!index_tokens.contains(&"databas".to_string()));
528 }
529
530 #[test]
531 fn simple_analyzer() {
532 let analyzer = SimpleAnalyzer;
533 let tokens = analyzer.analyze("Hello World foo");
534 assert_eq!(tokens, vec!["hello", "world", "foo"]);
535 }
536
537 #[test]
538 fn keyword_analyzer() {
539 let analyzer = KeywordAnalyzer;
540 let tokens = analyzer.analyze("Active Status");
541 assert_eq!(tokens, vec!["active status"]);
542 }
543
544 #[test]
545 fn language_analyzer_german() {
546 let analyzer = LanguageAnalyzer::new("german").unwrap();
547 let tokens = analyzer.analyze("Die Datenbanken sind schnell");
548 assert!(!tokens.is_empty());
550 assert!(tokens.iter().all(|t| t == &t.to_lowercase()));
551 }
552
553 #[test]
554 fn ngram_analyzer() {
555 let analyzer = NgramAnalyzer::new(3, 4);
556 let tokens = analyzer.analyze("hello");
557 assert_eq!(tokens.len(), 5);
560 assert!(tokens.contains(&"hel".to_string()));
561 assert!(tokens.contains(&"ell".to_string()));
562 assert!(tokens.contains(&"llo".to_string()));
563 assert!(tokens.contains(&"hell".to_string()));
564 assert!(tokens.contains(&"ello".to_string()));
565 }
566
567 #[test]
568 fn ngram_short_word() {
569 let analyzer = NgramAnalyzer::new(3, 5);
570 let tokens = analyzer.analyze("ab");
571 assert!(tokens.is_empty());
573 }
574
575 #[test]
576 fn edge_ngram_analyzer() {
577 let analyzer = EdgeNgramAnalyzer::new(2, 5);
578 let tokens = analyzer.analyze("database");
579 assert_eq!(tokens.len(), 4);
581 assert_eq!(tokens[0], "da");
582 assert_eq!(tokens[1], "dat");
583 assert_eq!(tokens[2], "data");
584 assert_eq!(tokens[3], "datab");
585 }
586
587 #[test]
588 fn edge_ngram_multiple_words() {
589 let analyzer = EdgeNgramAnalyzer::new(2, 3);
590 let tokens = analyzer.analyze("foo bar");
591 assert_eq!(tokens.len(), 4);
593 assert!(tokens.contains(&"fo".to_string()));
594 assert!(tokens.contains(&"foo".to_string()));
595 assert!(tokens.contains(&"ba".to_string()));
596 assert!(tokens.contains(&"bar".to_string()));
597 }
598
599 #[test]
600 fn registry_ngram_with_params() {
601 let mut registry = AnalyzerRegistry::new();
602 assert!(registry.set_analyzer("col", "ngram:2:3"));
603 let tokens = registry.analyze_for_index("col", "hello");
604 assert_eq!(tokens.len(), 7);
606 assert!(tokens.contains(&"he".to_string()));
607 }
608
609 #[test]
610 fn registry_edge_ngram() {
611 let mut registry = AnalyzerRegistry::new();
612 assert!(registry.set_analyzer("col", "edge_ngram:1:3"));
613 let tokens = registry.analyze_for_index("col", "test");
614 assert_eq!(tokens.len(), 3);
616 assert_eq!(tokens[0], "t");
617 assert_eq!(tokens[2], "tes");
618 }
619}