use std::collections::BTreeMap;
use super::exposure::ToolCatalogEntry;
const K1: f64 = 1.2;
const B: f64 = 0.75;
const RELATED_WEIGHT: f64 = 0.3;
const MIN_PREFIX_LEN: usize = 4;
#[derive(Clone, Copy)]
enum Field {
Name,
Summary,
Details,
}
impl Field {
const ALL: [Self; 3] = [Self::Name, Self::Summary, Self::Details];
fn weight(self) -> f64 {
match self {
Self::Name => 3.0,
Self::Summary => 1.5,
Self::Details => 1.0,
}
}
}
struct PerField<T> {
name: T,
summary: T,
details: T,
}
impl<T> PerField<T> {
fn get(&self, field: Field) -> &T {
match field {
Field::Name => &self.name,
Field::Summary => &self.summary,
Field::Details => &self.details,
}
}
fn from_fn(mut value: impl FnMut(Field) -> T) -> Self {
Self {
name: value(Field::Name),
summary: value(Field::Summary),
details: value(Field::Details),
}
}
}
pub(super) fn search_entries<'a>(
entries: &'a [ToolCatalogEntry],
query: &str,
limit: usize,
) -> Vec<&'a ToolCatalogEntry> {
if !query.chars().any(char::is_alphanumeric) {
return entries.iter().take(limit).collect();
}
let index = Index::new(entries);
let mut query_words: Vec<_> = words(query).collect();
query_words.sort_unstable();
query_words.dedup();
let exact_name = query.trim();
let terms: Vec<_> = query_words
.iter()
.map(|word| index.term(word))
.filter(|term| !term.matches.is_empty())
.collect();
let mut hits: Vec<_> = entries
.iter()
.zip(&index.documents)
.filter_map(|(entry, document)| {
let score = if entry.name == exact_name {
f64::INFINITY
} else {
index.score(document, &terms)
};
(score > 0.0).then_some((score, entry))
})
.collect();
hits.sort_by(|(left, _), (right, _)| right.total_cmp(left));
hits.into_iter()
.take(limit)
.map(|(_, entry)| entry)
.collect()
}
#[derive(Default)]
struct FieldWords {
counts: BTreeMap<String, usize>,
len: usize,
}
impl FieldWords {
fn new(words: impl IntoIterator<Item = String>) -> Self {
let mut field = Self::default();
for word in words {
*field.counts.entry(word).or_default() += 1;
field.len += 1;
}
field
}
}
type Document = PerField<FieldWords>;
fn document(entry: &ToolCatalogEntry) -> Document {
let (kind, name) = entry
.name
.split_once("__")
.unwrap_or(("", entry.name.as_str()));
let (summary, rest) = entry
.description
.split_once("\n\n")
.unwrap_or((&entry.description, ""));
let parameters = entry.parameters["properties"]
.as_object()
.into_iter()
.flat_map(|properties| properties.keys())
.flat_map(|name| words(name));
PerField {
name: FieldWords::new(words(name)),
summary: FieldWords::new(words(summary)),
details: FieldWords::new(words(rest).chain(parameters).chain(words(kind))),
}
}
struct Term<'a> {
matches: Vec<(&'a str, f64)>,
idf: f64,
}
struct Index {
documents: Vec<Document>,
average_len: PerField<f64>,
vocabulary: BTreeMap<String, String>,
}
impl Index {
fn new(entries: &[ToolCatalogEntry]) -> Self {
let documents: Vec<_> = entries.iter().map(document).collect();
let count = documents.len().max(1) as f64;
let average_len = PerField::from_fn(|field| {
documents
.iter()
.map(|document| document.get(field).len as f64)
.sum::<f64>()
/ count
});
let vocabulary = documents
.iter()
.flat_map(|document| Field::ALL.map(|field| document.get(field)))
.flat_map(|field| field.counts.keys())
.map(|word| (word.clone(), stem(word)))
.collect();
Self {
documents,
average_len,
vocabulary,
}
}
fn term(&self, word: &str) -> Term<'_> {
let word_stem = stem(word);
let mut matches = Vec::new();
let mut same_stem = Vec::new();
for (candidate, candidate_stem) in &self.vocabulary {
let weight = if candidate == word {
1.0
} else if *candidate_stem == word_stem || extends(&word_stem, candidate_stem) {
RELATED_WEIGHT
} else {
continue;
};
matches.push((candidate.as_str(), weight));
if *candidate_stem == word_stem {
same_stem.push(candidate.as_str());
}
}
let rarity_words = if same_stem.is_empty() {
matches.iter().map(|(word, _)| *word).collect()
} else {
same_stem
};
let containing = self
.documents
.iter()
.filter(|document| {
Field::ALL.into_iter().any(|field| {
let counts = &document.get(field).counts;
rarity_words.iter().any(|word| counts.contains_key(*word))
})
})
.count() as f64;
let total = self.documents.len() as f64;
let idf = (1.0 + (total - containing + 0.5) / (containing + 0.5)).ln();
Term { matches, idf }
}
fn score(&self, document: &Document, terms: &[Term<'_>]) -> f64 {
terms
.iter()
.map(|term| {
let frequency: f64 = Field::ALL
.into_iter()
.map(|field| {
let words = document.get(field);
let count: f64 = term
.matches
.iter()
.filter_map(|(word, weight)| {
words.counts.get(*word).map(|count| *count as f64 * weight)
})
.sum();
if count == 0.0 {
return 0.0;
}
let relative_len = words.len as f64 / self.average_len.get(field);
field.weight() * count / (1.0 - B + B * relative_len)
})
.sum();
term.idf * frequency / (K1 + frequency)
})
.sum()
}
}
fn extends(left: &str, right: &str) -> bool {
left.len().min(right.len()) >= MIN_PREFIX_LEN
&& (left.starts_with(right) || right.starts_with(left))
}
fn words(text: &str) -> impl Iterator<Item = String> + '_ {
text.split(|c: char| !c.is_alphanumeric())
.flat_map(|token| {
let parts = camel_case_words(token);
let whole = (parts.len() > 1).then_some(token);
whole.into_iter().chain(parts)
})
.map(str::to_lowercase)
.filter(|word| !STOPWORDS.contains(&word.as_str()))
}
fn camel_case_words(word: &str) -> Vec<&str> {
let chars: Vec<(usize, char)> = word.char_indices().collect();
let mut words = Vec::new();
let mut start = 0;
for (position, &(index, current)) in chars.iter().enumerate().skip(1) {
let previous = chars[position - 1].1;
let next = chars.get(position + 1).map(|&(_, next)| next);
let lower_to_upper =
current.is_uppercase() && (previous.is_lowercase() || previous.is_numeric());
let acronym_end = current.is_uppercase()
&& previous.is_uppercase()
&& next.is_some_and(char::is_lowercase);
if lower_to_upper || acronym_end {
words.push(&word[start..index]);
start = index;
}
}
if start < word.len() {
words.push(&word[start..]);
}
words
}
const STOPWORDS: &[&str] = &[
"a", "an", "and", "are", "as", "at", "be", "by", "for", "from", "in", "into", "is", "it",
"its", "of", "on", "or", "that", "the", "this", "to", "with",
];
const MIN_STEM_LEN: usize = 3;
fn stem(word: &str) -> String {
let mut stem = word.to_owned();
if let Some(base) = stem.strip_suffix("ies").filter(|base| base.len() >= 2) {
stem = format!("{base}y");
} else if stem.ends_with('s') && !stem.ends_with("ss") && !stem.ends_with("us") {
trim_to_stem(&mut stem, "s");
}
if trim_verb_to_stem(&mut stem, "ing") || trim_verb_to_stem(&mut stem, "ed") {
undouble(&mut stem);
}
trim_to_stem(&mut stem, "e");
stem
}
fn trim_to_stem(word: &mut String, suffix: &str) -> bool {
let keep = word.len().saturating_sub(suffix.len());
let strips = word.ends_with(suffix) && word.is_char_boundary(keep) && keep >= MIN_STEM_LEN;
if strips {
word.truncate(keep);
}
strips
}
fn trim_verb_to_stem(word: &mut String, suffix: &str) -> bool {
let has_vowel = word
.strip_suffix(suffix)
.is_some_and(|base| base.contains(['a', 'e', 'i', 'o', 'u', 'y']));
has_vowel && trim_to_stem(word, suffix)
}
fn undouble(word: &mut String) {
let mut tail = word.chars().rev();
if let (Some(last), Some(previous)) = (tail.next(), tail.next()) {
if last == previous
&& last.is_ascii_alphabetic()
&& !matches!(last, 'a' | 'e' | 'i' | 'o' | 'u' | 'l' | 's' | 'z')
{
word.pop();
}
}
}
#[cfg(test)]
#[path = "search_tests.rs"]
mod tests;