use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use scryer_db::{DependencyPackage, ProjectDependency, ScryerDb};
use super::cache::{IndexKey, SearchIndexCache, SymbolDoc};
use super::tokenize::{tokenize_identifier, tokenize_query, tokenize_text};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SearchScope {
#[default]
Project,
Dependencies { crate_name: Option<String> },
}
#[derive(Debug, Clone, Default)]
pub struct SearchQuery {
pub text: Option<String>,
pub like_symbol: Option<String>,
pub kinds: Vec<String>,
pub file_filter: Option<String>,
pub scope: SearchScope,
pub offset: usize,
pub limit: usize,
}
#[derive(Debug, Clone)]
pub struct SymbolHit {
pub name: String,
pub qualified_name: String,
pub kind: String,
pub file_path: String,
pub start_line: u32,
pub end_line: u32,
pub signature: String,
pub score: f32,
pub matched_terms: Vec<String>,
pub crate_name: Option<String>,
pub version: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct SearchResults {
pub query_terms: Vec<String>,
pub like_symbol: Option<String>,
pub total: usize,
pub offset: usize,
pub has_more: bool,
pub hits: Vec<SymbolHit>,
}
type PackageNames = HashMap<u64, (String, String)>;
struct DependencyScope {
packages: PackageNames,
}
async fn resolve_dependency_scope(
db: &ScryerDb,
project_id: u64,
crate_name: Option<&str>,
) -> anyhow::Result<DependencyScope> {
let mut guard = db.lock().await;
let linked: HashSet<u64> =
ProjectDependency::filter(ProjectDependency::fields().project_id().eq(project_id))
.exec(&mut *guard)
.await?
.into_iter()
.map(|l| l.dependency_package_id)
.collect();
let all = DependencyPackage::all().exec(&mut *guard).await?;
let selected: Vec<DependencyPackage> = match crate_name {
Some(name) => {
let named: Vec<DependencyPackage> = all
.into_iter()
.filter(|p| p.name.eq_ignore_ascii_case(name))
.collect();
if named.iter().any(|p| linked.contains(&p.id)) {
named
.into_iter()
.filter(|p| linked.contains(&p.id))
.collect()
} else {
named
}
}
None => all.into_iter().filter(|p| linked.contains(&p.id)).collect(),
};
Ok(DependencyScope {
packages: selected
.into_iter()
.map(|p| (p.id, (p.name, p.version)))
.collect(),
})
}
fn find_like_target<'a>(
docs: &'a [SymbolDoc],
target: &str,
in_scope: &impl Fn(&SymbolDoc) -> bool,
) -> Option<&'a SymbolDoc> {
let suffix = format!("::{target}");
docs.iter()
.filter(|d| in_scope(d))
.find(|d| d.name == target)
.or_else(|| {
docs.iter()
.filter(|d| in_scope(d))
.find(|d| d.qualified_name == target)
})
.or_else(|| {
docs.iter()
.filter(|d| in_scope(d))
.find(|d| d.qualified_name.ends_with(&suffix))
})
}
fn like_terms(doc: &SymbolDoc) -> Vec<String> {
let mut terms = Vec::new();
for t in tokenize_identifier(&doc.name)
.into_iter()
.chain(tokenize_text(doc.parent_path()))
.chain(tokenize_text(&doc.signature))
.chain(tokenize_text(&doc.doc_snippet))
{
if !terms.contains(&t) {
terms.push(t);
}
}
terms
}
pub(crate) async fn search_symbols(
db: &ScryerDb,
rayon_pool: &Arc<rayon::ThreadPool>,
cache: &SearchIndexCache,
project_id: u64,
query: SearchQuery,
) -> anyhow::Result<SearchResults> {
anyhow::ensure!(
query.text.is_some() != query.like_symbol.is_some(),
"Exactly one of `query` or `like_symbol` must be provided"
);
let (key, packages): (IndexKey, Option<PackageNames>) = match &query.scope {
SearchScope::Project => ((project_id, None), None),
SearchScope::Dependencies { crate_name } => {
let scope = resolve_dependency_scope(db, project_id, crate_name.as_deref()).await?;
if scope.packages.is_empty() {
return Ok(SearchResults {
offset: query.offset,
..SearchResults::default()
});
}
let key = if scope.packages.len() == 1 {
(0, scope.packages.keys().next().copied())
} else {
(0, None)
};
(key, Some(scope.packages))
}
};
let index = cache.get_or_build(db, rayon_pool, key).await?;
let kinds: Vec<String> = query.kinds.iter().map(|k| k.to_lowercase()).collect();
let file_filter = query.file_filter.as_deref().map(str::to_lowercase);
let in_scope = |d: &SymbolDoc| {
packages.as_ref().is_none_or(|pkgs| {
d.dependency_package_id
.is_some_and(|id| pkgs.contains_key(&id))
})
};
let (terms, exclude_id, like_name) = match (&query.text, &query.like_symbol) {
(Some(text), _) => (tokenize_query(text), None, None),
(None, Some(target)) => {
let doc = find_like_target(index.index.docs(), target, &in_scope).ok_or_else(|| {
anyhow::anyhow!(
"Symbol `{target}` not found in this scope; try a `query` search instead"
)
})?;
(
like_terms(doc),
Some(doc.id),
Some(doc.qualified_name.clone()),
)
}
(None, None) => unreachable!("validated above"),
};
let hits = index.index.search(&terms, |d| {
in_scope(d)
&& Some(d.id) != exclude_id
&& (kinds.is_empty() || kinds.contains(&d.kind.to_lowercase()))
&& file_filter
.as_ref()
.is_none_or(|f| d.file_path.to_lowercase().contains(f))
});
let total = hits.len();
let page: Vec<SymbolHit> = hits
.into_iter()
.skip(query.offset)
.take(query.limit)
.map(|hit| {
let d = index.index.doc(hit.doc_idx);
let pkg = packages
.as_ref()
.zip(d.dependency_package_id)
.and_then(|(pkgs, id)| pkgs.get(&id));
SymbolHit {
name: d.name.clone(),
qualified_name: d.qualified_name.clone(),
kind: d.kind.clone(),
file_path: d.file_path.clone(),
start_line: d.start_line,
end_line: d.end_line,
signature: d.signature.clone(),
score: hit.score,
matched_terms: hit.matched_terms,
crate_name: pkg.map(|(n, _)| n.clone()),
version: pkg.map(|(_, v)| v.clone()),
}
})
.collect();
Ok(SearchResults {
query_terms: terms,
like_symbol: like_name,
total,
offset: query.offset,
has_more: query.offset + page.len() < total,
hits: page,
})
}