use super::*;
impl<'db> QueryBuilder<'db> {
pub fn search_nodes(
&mut self,
query: &str,
options: SearchOptions,
) -> SqliteResult<Vec<SearchResult>> {
let limit = options.limit.unwrap_or(100) as usize;
let offset = options.offset.unwrap_or(0) as usize;
let parsed = parse_query(query);
let kinds = if parsed.kinds.is_empty() {
options.kinds.clone()
} else {
Some(
options
.kinds
.clone()
.unwrap_or_default()
.into_iter()
.chain(parsed.kinds.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect(),
)
};
let languages = if parsed.languages.is_empty() {
options.languages.clone()
} else {
Some(
options
.languages
.clone()
.unwrap_or_default()
.into_iter()
.chain(parsed.languages.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect(),
)
};
let text = parsed.text.clone();
let mut results = if text.is_empty() {
self.search_all_by_filters(kinds.clone(), languages.clone(), limit * 5)?
} else {
self.search_nodes_fts(&text, kinds.clone(), languages.clone(), limit, offset)?
};
if results.is_empty() && text.len() >= 2 {
results =
self.search_nodes_like(&text, kinds.clone(), languages.clone(), limit, offset)?;
}
if results.is_empty() && text.len() >= 3 {
results = self.search_nodes_fuzzy(&text, kinds.clone(), languages.clone(), limit)?;
}
if !results.is_empty() && !query.is_empty() {
let mut existing_ids = results
.iter()
.map(|result| result.node.id.clone())
.collect::<HashSet<_>>();
let max_fts_score = results
.iter()
.map(|result| result.score)
.fold(f64::NEG_INFINITY, f64::max);
for term in query.split_whitespace().filter(|term| term.len() >= 2) {
let mut sql = "SELECT * FROM nodes WHERE name = ? COLLATE NOCASE".to_string();
let mut params = vec![SqliteValue::from(term)];
append_kind_language_filters(
&mut sql,
&mut params,
"kind",
"language",
&kinds,
&languages,
)?;
sql.push_str(" LIMIT 20");
let mut stmt = self.db.prepare(&sql)?;
for row in stmt.all(SqliteParams::positional(params))? {
let node = row_to_node(&row)?;
if existing_ids.insert(node.id.clone()) {
results.push(SearchResult {
node,
score: max_fts_score,
highlights: None,
});
}
}
}
}
if !results.is_empty() && (!text.is_empty() || !query.is_empty()) {
let scoring_query = if text.is_empty() { query } else { &text };
for result in &mut results {
result.score += kind_bonus(result.node.kind)
+ score_path_relevance(
&result.node.file_path,
scoring_query,
Some(&self.project_name_tokens),
)
+ name_match_bonus(&result.node.name, scoring_query);
}
results.sort_by(|a, b| b.score.total_cmp(&a.score));
if results.len() > limit {
results.truncate(limit);
}
}
if !parsed.path_filters.is_empty() {
let lowered = parsed
.path_filters
.iter()
.map(|path| path.to_lowercase())
.collect::<Vec<_>>();
results.retain(|result| {
let file_path = result.node.file_path.to_lowercase();
lowered.iter().any(|path| file_path.contains(path))
});
}
if !parsed.name_filters.is_empty() {
let lowered = parsed
.name_filters
.iter()
.map(|name| name.to_lowercase())
.collect::<Vec<_>>();
results.retain(|result| {
let name = result.node.name.to_lowercase();
lowered.iter().any(|filter| name.contains(filter))
});
}
Ok(results)
}
fn search_all_by_filters(
&mut self,
kinds: Option<Vec<NodeKind>>,
languages: Option<Vec<Language>>,
limit: usize,
) -> SqliteResult<Vec<SearchResult>> {
let mut sql = "SELECT * FROM nodes WHERE 1=1".to_string();
let mut params = Vec::new();
append_kind_language_filters(
&mut sql,
&mut params,
"kind",
"language",
&kinds,
&languages,
)?;
sql.push_str(" ORDER BY name LIMIT ?");
params.push(SqliteValue::from(limit));
let mut stmt = self.db.prepare(&sql)?;
stmt.all(SqliteParams::positional(params))?
.into_iter()
.map(|row| {
Ok(SearchResult {
node: row_to_node(&row)?,
score: 1.0,
highlights: None,
})
})
.collect()
}
fn search_nodes_fuzzy(
&mut self,
text: &str,
kinds: Option<Vec<NodeKind>>,
languages: Option<Vec<Language>>,
limit: usize,
) -> SqliteResult<Vec<SearchResult>> {
let lowered = text.to_lowercase();
let max_dist = if lowered.len() <= 4 { 1 } else { 2 };
let mut candidates = self
.get_all_node_names()?
.into_iter()
.filter_map(|name| {
let dist = bounded_edit_distance(&name.to_lowercase(), &lowered, max_dist);
if dist <= max_dist {
Some((name, dist))
} else {
None
}
})
.collect::<Vec<_>>();
candidates.sort_by_key(|(_, dist)| *dist);
let followup_cap = std::cmp::max(limit * 2, 50);
let mut results = Vec::new();
let mut seen = HashSet::new();
for (name, dist) in candidates.into_iter().take(followup_cap) {
if results.len() >= limit {
break;
}
let mut sql = "SELECT * FROM nodes WHERE name = ?".to_string();
let mut params = vec![SqliteValue::from(name)];
append_kind_language_filters(
&mut sql,
&mut params,
"kind",
"language",
&kinds,
&languages,
)?;
sql.push_str(" LIMIT 5");
let mut stmt = self.db.prepare(&sql)?;
for row in stmt.all(SqliteParams::positional(params))? {
let node = row_to_node(&row)?;
if !seen.insert(node.id.clone()) {
continue;
}
results.push(SearchResult {
node,
score: 1.0 / (1.0 + dist as f64),
highlights: None,
});
if results.len() >= limit {
break;
}
}
}
Ok(results)
}
fn search_nodes_fts(
&mut self,
query: &str,
kinds: Option<Vec<NodeKind>>,
languages: Option<Vec<Language>>,
limit: usize,
offset: usize,
) -> SqliteResult<Vec<SearchResult>> {
let fts_query = query
.replace("::", " ")
.chars()
.map(|ch| if "'\"*():^".contains(ch) { ' ' } else { ch })
.collect::<String>()
.split_whitespace()
.filter(|term| {
!matches!(
term.to_ascii_uppercase().as_str(),
"AND" | "OR" | "NOT" | "NEAR"
)
})
.map(|term| format!("\"{term}\"*"))
.collect::<Vec<_>>()
.join(" OR ");
if fts_query.is_empty() {
return Ok(Vec::new());
}
let fts_limit = std::cmp::max(limit * 5, 100);
let mut sql = r#"
SELECT nodes.*, bm25(nodes_fts, 0, 20, 5, 1, 2) as score
FROM nodes_fts
JOIN nodes ON nodes_fts.id = nodes.id
WHERE nodes_fts MATCH ?
"#
.to_string();
let mut params = vec![SqliteValue::from(fts_query)];
append_kind_language_filters(
&mut sql,
&mut params,
"nodes.kind",
"nodes.language",
&kinds,
&languages,
)?;
sql.push_str(" ORDER BY score LIMIT ? OFFSET ?");
params.push(SqliteValue::from(fts_limit));
params.push(SqliteValue::from(offset));
let mut stmt = self.db.prepare(&sql)?;
match stmt.all(SqliteParams::positional(params)) {
Ok(rows) => rows
.into_iter()
.map(|row| {
Ok(SearchResult {
node: row_to_node(&row)?,
score: row_value(&row, "score")
.ok()
.and_then(SqliteValue::as_f64)
.unwrap_or(0.0)
.abs(),
highlights: None,
})
})
.collect(),
Err(_) => Ok(Vec::new()),
}
}
fn search_nodes_like(
&mut self,
query: &str,
kinds: Option<Vec<NodeKind>>,
languages: Option<Vec<Language>>,
limit: usize,
offset: usize,
) -> SqliteResult<Vec<SearchResult>> {
let mut sql = r#"
SELECT nodes.*,
CASE
WHEN name = ? THEN 1.0
WHEN name LIKE ? THEN 0.9
WHEN name LIKE ? THEN 0.8
WHEN qualified_name LIKE ? THEN 0.7
ELSE 0.5
END as score
FROM nodes
WHERE (
name LIKE ? OR
qualified_name LIKE ? OR
name LIKE ?
)
"#
.to_string();
let starts_with = format!("{query}%");
let contains = format!("%{query}%");
let mut params = vec![
SqliteValue::from(query),
SqliteValue::from(starts_with.clone()),
SqliteValue::from(contains.clone()),
SqliteValue::from(contains.clone()),
SqliteValue::from(contains.clone()),
SqliteValue::from(contains),
SqliteValue::from(starts_with),
];
append_kind_language_filters(
&mut sql,
&mut params,
"kind",
"language",
&kinds,
&languages,
)?;
sql.push_str(" ORDER BY score DESC, length(name) ASC LIMIT ? OFFSET ?");
params.push(SqliteValue::from(limit));
params.push(SqliteValue::from(offset));
let mut stmt = self.db.prepare(&sql)?;
stmt.all(SqliteParams::positional(params))?
.into_iter()
.map(|row| {
Ok(SearchResult {
node: row_to_node(&row)?,
score: row_value(&row, "score")
.ok()
.and_then(SqliteValue::as_f64)
.unwrap_or(0.0),
highlights: None,
})
})
.collect()
}
}