use std::collections::HashMap;
use rust_stemmers::{Algorithm, Stemmer};
use unicode_normalization::UnicodeNormalization;
pub trait TextAnalyzer: Send + Sync {
fn analyze(&self, text: &str) -> Vec<String>;
fn name(&self) -> &str;
}
pub struct StandardAnalyzer;
impl TextAnalyzer for StandardAnalyzer {
fn analyze(&self, text: &str) -> Vec<String> {
analyze(text)
}
fn name(&self) -> &str {
"standard"
}
}
pub struct SimpleAnalyzer;
impl TextAnalyzer for SimpleAnalyzer {
fn analyze(&self, text: &str) -> Vec<String> {
text.to_lowercase()
.split_whitespace()
.filter(|w| w.len() > 1)
.map(|w| w.to_string())
.collect()
}
fn name(&self) -> &str {
"simple"
}
}
pub struct KeywordAnalyzer;
impl TextAnalyzer for KeywordAnalyzer {
fn analyze(&self, text: &str) -> Vec<String> {
let trimmed = text.trim().to_lowercase();
if trimmed.is_empty() {
Vec::new()
} else {
vec![trimmed]
}
}
fn name(&self) -> &str {
"keyword"
}
}
pub struct LanguageAnalyzer {
algorithm: Algorithm,
lang_name: String,
}
impl LanguageAnalyzer {
pub fn new(language: &str) -> Option<Self> {
let algorithm = match language.to_lowercase().as_str() {
"english" | "en" => Algorithm::English,
"german" | "de" => Algorithm::German,
"french" | "fr" => Algorithm::French,
"spanish" | "es" => Algorithm::Spanish,
"italian" | "it" => Algorithm::Italian,
"portuguese" | "pt" => Algorithm::Portuguese,
"dutch" | "nl" => Algorithm::Dutch,
"swedish" | "sv" => Algorithm::Swedish,
"norwegian" | "no" => Algorithm::Norwegian,
"danish" | "da" => Algorithm::Danish,
"finnish" | "fi" => Algorithm::Finnish,
"russian" | "ru" => Algorithm::Russian,
"turkish" | "tr" => Algorithm::Turkish,
"hungarian" | "hu" => Algorithm::Hungarian,
"romanian" | "ro" => Algorithm::Romanian,
_ => return None,
};
Some(Self {
algorithm,
lang_name: language.to_lowercase(),
})
}
}
impl TextAnalyzer for LanguageAnalyzer {
fn analyze(&self, text: &str) -> Vec<String> {
let stemmer = Stemmer::create(self.algorithm);
tokenize_with_stemmer(text, &stemmer)
}
fn name(&self) -> &str {
&self.lang_name
}
}
pub struct NgramAnalyzer {
min: usize,
max: usize,
}
impl NgramAnalyzer {
pub fn new(min: usize, max: usize) -> Self {
Self {
min: min.max(1),
max: max.max(min.max(1)),
}
}
}
impl TextAnalyzer for NgramAnalyzer {
fn analyze(&self, text: &str) -> Vec<String> {
let lower = text.to_lowercase();
let mut ngrams = Vec::new();
for word in lower.split(|c: char| !c.is_alphanumeric()) {
if word.is_empty() {
continue;
}
let chars: Vec<char> = word.chars().collect();
for n in self.min..=self.max {
if n > chars.len() {
break;
}
for window in chars.windows(n) {
ngrams.push(window.iter().collect());
}
}
}
ngrams
}
fn name(&self) -> &str {
"ngram"
}
}
pub struct EdgeNgramAnalyzer {
min: usize,
max: usize,
}
impl EdgeNgramAnalyzer {
pub fn new(min: usize, max: usize) -> Self {
Self {
min: min.max(1),
max: max.max(min.max(1)),
}
}
}
impl TextAnalyzer for EdgeNgramAnalyzer {
fn analyze(&self, text: &str) -> Vec<String> {
let lower = text.to_lowercase();
let mut ngrams = Vec::new();
for word in lower.split(|c: char| !c.is_alphanumeric()) {
if word.is_empty() {
continue;
}
let chars: Vec<char> = word.chars().collect();
for n in self.min..=self.max.min(chars.len()) {
ngrams.push(chars[..n].iter().collect());
}
}
ngrams
}
fn name(&self) -> &str {
"edge_ngram"
}
}
pub struct SynonymMap {
entries: HashMap<String, Vec<String>>,
}
impl SynonymMap {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
pub fn add(&mut self, term: &str, synonyms: &[&str]) {
let key = term.to_lowercase();
let vals: Vec<String> = synonyms.iter().map(|s| s.to_lowercase()).collect();
self.entries.insert(key, vals);
}
pub fn expand(&self, tokens: &[String]) -> Vec<String> {
let mut expanded = Vec::with_capacity(tokens.len() * 2);
for token in tokens {
expanded.push(token.clone());
if let Some(synonyms) = self.entries.get(token.as_str()) {
expanded.extend(synonyms.iter().cloned());
}
}
expanded
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl Default for SynonymMap {
fn default() -> Self {
Self::new()
}
}
pub struct AnalyzerRegistry {
overrides: HashMap<String, Box<dyn TextAnalyzer>>,
synonyms: HashMap<String, SynonymMap>,
}
impl AnalyzerRegistry {
pub fn new() -> Self {
Self {
overrides: HashMap::new(),
synonyms: HashMap::new(),
}
}
pub fn set_analyzer(&mut self, collection: &str, analyzer_name: &str) -> bool {
let analyzer: Box<dyn TextAnalyzer> = match analyzer_name {
"standard" => Box::new(StandardAnalyzer),
"simple" => Box::new(SimpleAnalyzer),
"keyword" => Box::new(KeywordAnalyzer),
"ngram" => Box::new(NgramAnalyzer::new(3, 4)),
"edge_ngram" => Box::new(EdgeNgramAnalyzer::new(2, 5)),
name if name.starts_with("ngram:") => {
let parts: Vec<&str> = name.splitn(3, ':').collect();
let min = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(3);
let max = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(4);
Box::new(NgramAnalyzer::new(min, max))
}
name if name.starts_with("edge_ngram:") => {
let parts: Vec<&str> = name.splitn(3, ':').collect();
let min = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(2);
let max = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(5);
Box::new(EdgeNgramAnalyzer::new(min, max))
}
lang => match LanguageAnalyzer::new(lang) {
Some(a) => Box::new(a),
None => return false,
},
};
self.overrides.insert(collection.to_string(), analyzer);
true
}
pub fn add_synonym(&mut self, collection: &str, term: &str, synonyms: &[&str]) {
self.synonyms
.entry(collection.to_string())
.or_default()
.add(term, synonyms);
}
pub fn get_synonyms(&self, collection: &str) -> Option<&SynonymMap> {
self.synonyms.get(collection)
}
pub fn analyze(&self, collection: &str, text: &str) -> Vec<String> {
let tokens = match self.overrides.get(collection) {
Some(analyzer) => analyzer.analyze(text),
None => analyze(text),
};
match self.synonyms.get(collection) {
Some(syn_map) if !syn_map.is_empty() => syn_map.expand(&tokens),
_ => tokens,
}
}
pub fn analyze_for_index(&self, collection: &str, text: &str) -> Vec<String> {
match self.overrides.get(collection) {
Some(analyzer) => analyzer.analyze(text),
None => analyze(text),
}
}
}
impl Default for AnalyzerRegistry {
fn default() -> Self {
Self::new()
}
}
pub fn analyze(text: &str) -> Vec<String> {
let stemmer = Stemmer::create(Algorithm::English);
tokenize_with_stemmer(text, &stemmer)
}
fn tokenize_with_stemmer(text: &str, stemmer: &Stemmer) -> Vec<String> {
let normalized: String = text
.nfd()
.filter(|c| !c.is_ascii() || !unicode_normalization::char::is_combining_mark(*c))
.flat_map(char::to_lowercase)
.collect();
let mut tokens = Vec::new();
for word in normalized.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_') {
let trimmed = word.trim_matches(|c: char| c == '-' || c == '_');
if trimmed.is_empty() {
continue;
}
if trimmed.len() <= 1 {
continue;
}
if is_stop_word(trimmed) {
continue;
}
let stemmed = stemmer.stem(trimmed);
if !stemmed.is_empty() {
tokens.push(stemmed.into_owned());
}
}
tokens
}
fn is_stop_word(word: &str) -> bool {
STOP_WORDS.binary_search(&word).is_ok()
}
static STOP_WORDS: &[&str] = &[
"a", "about", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "do", "for",
"from", "had", "has", "have", "he", "her", "him", "his", "how", "if", "in", "into", "is", "it",
"its", "just", "me", "my", "no", "not", "of", "on", "or", "our", "out", "own", "say", "she",
"so", "some", "than", "that", "the", "their", "them", "then", "there", "these", "they", "this",
"to", "too", "up", "us", "very", "was", "we", "were", "what", "when", "which", "who", "will",
"with", "would", "you", "your",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic_analysis() {
let tokens = analyze("The quick Brown FOX jumped over the lazy dog");
assert!(tokens.contains(&"quick".to_string()));
assert!(tokens.contains(&"brown".to_string()));
assert!(tokens.contains(&"fox".to_string()));
assert!(tokens.contains(&"jump".to_string())); assert!(tokens.contains(&"lazi".to_string())); assert!(tokens.contains(&"dog".to_string()));
assert!(!tokens.contains(&"the".to_string())); }
#[test]
fn stop_words_removed() {
let tokens = analyze("this is a test of the system");
assert_eq!(tokens, vec!["test", "system"]);
}
#[test]
fn stemming_works() {
let tokens = analyze("running databases distributed systems");
assert!(tokens.contains(&"run".to_string()));
assert!(tokens.contains(&"databas".to_string()));
assert!(tokens.contains(&"distribut".to_string()));
assert!(tokens.contains(&"system".to_string()));
}
#[test]
fn unicode_normalization() {
let tokens = analyze("cafe\u{0301}"); assert_eq!(tokens, vec!["cafe"]); }
#[test]
fn hyphenated_words_preserved() {
let tokens = analyze("e-mail real-time");
assert!(tokens.contains(&"e-mail".to_string()) || tokens.contains(&"email".to_string()));
assert!(
tokens.contains(&"real-tim".to_string()) || tokens.contains(&"real-time".to_string())
);
}
#[test]
fn empty_and_single_char_filtered() {
let tokens = analyze("I a x ");
assert!(tokens.is_empty());
}
#[test]
fn synonym_expansion() {
let mut syn = SynonymMap::new();
syn.add("db", &["databas", "rdbms"]);
let tokens = vec!["db".to_string(), "query".to_string()];
let expanded = syn.expand(&tokens);
assert_eq!(expanded.len(), 4); assert!(expanded.contains(&"databas".to_string()));
assert!(expanded.contains(&"rdbms".to_string()));
}
#[test]
fn analyzer_registry_with_synonyms() {
let mut registry = AnalyzerRegistry::new();
registry.add_synonym("docs", "db", &["databas"]);
let tokens = registry.analyze("docs", "db query");
assert!(tokens.contains(&"databas".to_string()));
let index_tokens = registry.analyze_for_index("docs", "db query");
assert!(!index_tokens.contains(&"databas".to_string()));
}
#[test]
fn simple_analyzer() {
let analyzer = SimpleAnalyzer;
let tokens = analyzer.analyze("Hello World foo");
assert_eq!(tokens, vec!["hello", "world", "foo"]);
}
#[test]
fn keyword_analyzer() {
let analyzer = KeywordAnalyzer;
let tokens = analyzer.analyze("Active Status");
assert_eq!(tokens, vec!["active status"]);
}
#[test]
fn language_analyzer_german() {
let analyzer = LanguageAnalyzer::new("german").unwrap();
let tokens = analyzer.analyze("Die Datenbanken sind schnell");
assert!(!tokens.is_empty());
assert!(tokens.iter().all(|t| t == &t.to_lowercase()));
}
#[test]
fn ngram_analyzer() {
let analyzer = NgramAnalyzer::new(3, 4);
let tokens = analyzer.analyze("hello");
assert_eq!(tokens.len(), 5);
assert!(tokens.contains(&"hel".to_string()));
assert!(tokens.contains(&"ell".to_string()));
assert!(tokens.contains(&"llo".to_string()));
assert!(tokens.contains(&"hell".to_string()));
assert!(tokens.contains(&"ello".to_string()));
}
#[test]
fn ngram_short_word() {
let analyzer = NgramAnalyzer::new(3, 5);
let tokens = analyzer.analyze("ab");
assert!(tokens.is_empty());
}
#[test]
fn edge_ngram_analyzer() {
let analyzer = EdgeNgramAnalyzer::new(2, 5);
let tokens = analyzer.analyze("database");
assert_eq!(tokens.len(), 4);
assert_eq!(tokens[0], "da");
assert_eq!(tokens[1], "dat");
assert_eq!(tokens[2], "data");
assert_eq!(tokens[3], "datab");
}
#[test]
fn edge_ngram_multiple_words() {
let analyzer = EdgeNgramAnalyzer::new(2, 3);
let tokens = analyzer.analyze("foo bar");
assert_eq!(tokens.len(), 4);
assert!(tokens.contains(&"fo".to_string()));
assert!(tokens.contains(&"foo".to_string()));
assert!(tokens.contains(&"ba".to_string()));
assert!(tokens.contains(&"bar".to_string()));
}
#[test]
fn registry_ngram_with_params() {
let mut registry = AnalyzerRegistry::new();
assert!(registry.set_analyzer("col", "ngram:2:3"));
let tokens = registry.analyze_for_index("col", "hello");
assert_eq!(tokens.len(), 7);
assert!(tokens.contains(&"he".to_string()));
}
#[test]
fn registry_edge_ngram() {
let mut registry = AnalyzerRegistry::new();
assert!(registry.set_analyzer("col", "edge_ngram:1:3"));
let tokens = registry.analyze_for_index("col", "test");
assert_eq!(tokens.len(), 3);
assert_eq!(tokens[0], "t");
assert_eq!(tokens[2], "tes");
}
}