mod score;
pub(crate) use score::{Boosts, Feature, confidence, match_positions, match_quality, path_stem};
use std::collections::HashSet;
use std::path::Path;
use std::time::Instant;
use crate::core::now_unix;
use crate::store::{CandidateFilter, Store, SymbolRow};
const CANDIDATE_LIMIT: usize = 8000;
const LIVE_REPO_ID: i64 = -1;
const BRANCH_FILE_BOOST: f64 = 180.0;
const BRANCH_DIR_BOOST: f64 = 60.0;
#[derive(Debug, Default, Clone)]
pub(crate) struct ActiveFiles {
files: HashSet<String>,
dirs: HashSet<String>,
}
impl ActiveFiles {
pub(crate) fn new<I: IntoIterator<Item = String>>(paths: I) -> Self {
let files: HashSet<String> = paths.into_iter().collect();
let dirs = files
.iter()
.filter_map(|f| parent_dir(f))
.map(str::to_string)
.collect();
ActiveFiles { files, dirs }
}
fn is_empty(&self) -> bool {
self.files.is_empty()
}
fn boost(&self, path: &str) -> f64 {
if self.files.contains(path) {
BRANCH_FILE_BOOST
} else if parent_dir(path).is_some_and(|d| self.dirs.contains(d)) {
BRANCH_DIR_BOOST
} else {
0.0
}
}
}
fn dir_segments(path: &str) -> Vec<&str> {
parent_dir(path).map_or_else(Vec::new, |d| d.split('/').collect())
}
fn parent_dir(path: &str) -> Option<&str> {
path.rfind('/').map(|i| &path[..i])
}
const ENCLOSING_STEP: f64 = 60.0;
const MAX_ENCLOSING: f64 = 180.0;
const SAME_FILE_BOOST: f64 = 90.0;
const SAME_DIR_BOOST: f64 = 60.0;
const MIN_PROXIMITY: f64 = 5.0;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Anchor {
identity: String,
file: String,
scope: Vec<String>,
}
impl Anchor {
pub(crate) fn new(identity: String, file: String, line: i64, defs: &[SymbolRow]) -> Self {
let innermost = defs
.iter()
.filter(|d| d.line <= line && line <= d.end_line.unwrap_or(d.line))
.max_by_key(|d| (d.line, std::cmp::Reverse(d.end_line)));
let scope = innermost.map_or_else(Vec::new, |d| {
let mut scope = d.parent.as_deref().map_or_else(Vec::new, score::segments);
scope.push(d.name.to_ascii_lowercase());
scope
});
Anchor {
identity,
file,
scope,
}
}
fn enclosing(&self, parent: Option<&str>) -> f64 {
let Some(parent) = parent else {
return 0.0;
};
let p = score::segments(parent);
if p.is_empty() || p.len() > self.scope.len() || p[..] != self.scope[..p.len()] {
return 0.0;
}
(ENCLOSING_STEP * p.len() as f64).min(MAX_ENCLOSING)
}
fn proximity(&self, identity: &str, file: &str) -> f64 {
if identity != self.identity {
return 0.0;
}
if file == self.file {
return SAME_FILE_BOOST;
}
let (a, b) = (dir_segments(&self.file), dir_segments(file));
let common = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
let steps = (a.len() - common) + (b.len() - common);
let boost = SAME_DIR_BOOST * 0.5_f64.powi(steps as i32);
if boost < MIN_PROXIMITY { 0.0 } else { boost }
}
}
#[derive(Debug, Default, Clone)]
pub(crate) struct Context {
pub active: ActiveFiles,
pub anchor: Option<Anchor>,
}
impl Context {
fn boosts(&self, c: &SymbolRow, recency: f64) -> Boosts {
let (enclosing, proximity) = self.anchor.as_ref().map_or((0.0, 0.0), |a| {
(
a.enclosing(c.parent.as_deref()),
a.proximity(&c.repo_identity, &c.file),
)
});
Boosts {
recency,
branch: if self.active.is_empty() {
0.0
} else {
self.active.boost(&c.file)
},
enclosing,
proximity,
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub(crate) struct Hit {
pub name: String,
pub kind: String,
pub language: String,
pub file: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub root: Option<String>,
pub line: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_line: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<String>,
#[serde(rename = "repo")]
pub repo_identity: String,
#[serde(skip)]
pub score: f64,
pub confidence: f64,
#[serde(serialize_with = "serialize_feature_names")]
pub features: Vec<Feature>,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(skip_serializing_if = "is_one")]
pub declarations: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub also_in: Vec<String>,
pub total: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub explain: Option<std::collections::BTreeMap<String, f64>>,
}
fn serialize_feature_names<S: serde::Serializer>(
features: &[Feature],
s: S,
) -> Result<S::Ok, S::Error> {
use serde::Serialize;
let mut sorted: Vec<&Feature> = features.iter().collect();
sorted.sort_by(|a, b| b.value.total_cmp(&a.value));
let names: Vec<&str> = sorted.iter().map(|f| f.name).collect();
names.serialize(s)
}
pub(crate) struct Matches {
pub hits: Vec<Hit>,
pub total: usize,
}
fn is_one(n: &usize) -> bool {
*n <= 1
}
impl std::ops::Deref for Matches {
type Target = [Hit];
fn deref(&self) -> &[Hit] {
&self.hits
}
}
pub(crate) fn search(
store: &Store,
query: &str,
current_repo_id: Option<i64>,
only_repo: Option<i64>,
ctx: &Context,
limit: usize,
) -> crate::store::Result<Matches> {
let run =
|q: &str, typo: bool| search_query(store, q, current_repo_id, only_repo, ctx, limit, typo);
if !query.contains('.') {
return run(query, true);
}
let m = run(query, false)?;
if found(&m) {
return Ok(m);
}
if let (leaf, Some(owner)) = score::parse_qualified(query)
&& leaf.eq_ignore_ascii_case("new")
{
let mut m = run(owner, false)?;
constructor_owner(&mut m.hits);
m.total = m.hits.len();
if found(&m) {
return Ok(m);
}
}
let glob = run(&query.replace('.', "?"), false)?;
if found(&glob) {
return Ok(glob);
}
run(query, true)
}
fn constructor_owner(hits: &mut Vec<Hit>) {
hits.retain(|h| {
matches!(h.kind.as_str(), "class" | "struct")
&& h.features.iter().any(|f| f.name == "exact")
});
for h in hits {
h.features.push(score::Feature {
name: "constructor_owner",
value: 0.0,
});
}
}
fn found(m: &Matches) -> bool {
m.hits.iter().any(|h| h.score > 0.0)
}
fn search_query(
store: &Store,
query: &str,
current_repo_id: Option<i64>,
only_repo: Option<i64>,
ctx: &Context,
limit: usize,
typo: bool,
) -> crate::store::Result<Matches> {
let (leaf, qualifier) = score::parse_qualified(query);
let stripped;
let recall = if score::has_wildcard(leaf) {
stripped = score::strip_wildcards(leaf);
stripped.as_str()
} else {
leaf
};
let trace_on = crate::trace::enabled();
let t = std::time::Instant::now();
let mut candidates = store.search_candidates(
recall,
CANDIDATE_LIMIT,
score::has_wildcard(leaf),
only_repo,
Some(could_match(query, recall)),
)?;
if qualifier.is_some() && leaf.eq_ignore_ascii_case("new") {
for name in crate::lang::constructors() {
candidates.extend(store.search_candidates(
name,
CANDIDATE_LIMIT,
false,
only_repo,
Some(could_match(name, name)),
)?);
}
}
let n_candidates = candidates.len();
let t_recall = t.elapsed();
let t = std::time::Instant::now();
let now = now_unix();
let rank = |candidates: &[SymbolRow], near_miss: bool| -> Vec<Hit> {
candidates
.iter()
.filter_map(|c| {
let recency = recency_boost(c.git_ts.max(c.mtime.map(|n| n / 1_000_000_000)), now);
let boosts = ctx.boosts(c, recency);
rank_one(query, c, current_repo_id, boosts, near_miss)
})
.collect()
};
let mut hits = rank(&candidates, false);
let literal = hits
.iter()
.any(|h| h.score > 0.0 && score::is_literal(&h.features));
if typo && qualifier.is_none() && !literal {
let bar = if hits.iter().all(|h| h.score <= 0.0) {
f64::NEG_INFINITY
} else {
hits.iter()
.map(|h| score::name_evidence(&h.features))
.fold(f64::NEG_INFINITY, f64::max)
};
let near: Vec<SymbolRow> = candidates
.iter()
.filter(|c| score::near_miss_possible(query, &c.name))
.cloned()
.collect();
hits.extend(rank(&near, true).into_iter().filter(|h| {
h.features.iter().any(|f| f.name == "typo") && score::name_evidence(&h.features) >= bar
}));
}
if typo && qualifier.is_some() && hits.iter().all(|h| h.score <= 0.0) {
let retried = rank(&candidates, true);
if !retried.is_empty() {
hits = retried;
}
}
let n_hits = hits.len();
let t_score = t.elapsed();
let t = std::time::Instant::now();
let total = sort_and_truncate(&mut hits, limit);
crate::profile::record("recall", t_recall, || format!("{n_candidates} candidates"));
crate::profile::record("score", t_score, || format!("{n_hits} hits"));
crate::profile::record("sort", t.elapsed(), || format!("top {limit}"));
if trace_on {
crate::trace!(
"search {query:?}: recall {n_candidates} cand in {} ms, score→{n_hits} hits in {} ms, sort {} ms",
t_recall.as_millis(),
t_score.as_millis(),
t.elapsed().as_millis(),
);
}
Ok(Matches { hits, total })
}
fn could_match(query: &str, recall: &str) -> CandidateFilter {
let (query, recall) = (query.to_string(), recall.to_string());
Box::new(move |name, kind, file| score::could_match(&query, &recall, name, kind, file))
}
pub(crate) fn scope_miss_owner(
store: &Store,
query: &str,
current_repo_id: Option<i64>,
only_repo: Option<i64>,
ctx: &Context,
) -> Option<String> {
let (leaf, qualifier) = score::parse_qualified(query);
qualifier?;
let bare = search(store, leaf, current_repo_id, only_repo, ctx, 1).ok()?;
let hit = bare.hits.first()?;
Some(match &hit.parent {
Some(parent) => format!("{parent} ({}:{})", hit.file, hit.line),
None => format!("{}:{}", hit.file, hit.line),
})
}
fn recency_boost(mtime: Option<i64>, now: i64) -> f64 {
let Some(mtime) = mtime else {
return 0.0;
};
let age_days = (now - mtime).max(0) as f64 / 86_400.0;
let boost = 120.0 * 0.5_f64.powf(age_days / 14.0);
if boost < 1.0 { 0.0 } else { boost }
}
pub(crate) fn literal_leaf(query: &str) -> Option<&str> {
let (leaf, _) = score::parse_qualified(query);
(!leaf.is_empty() && !score::has_wildcard(leaf)).then_some(leaf)
}
pub(crate) fn live_search(
root: &Path,
query: &str,
limit: usize,
skip: &HashSet<String>,
deadline: Option<Instant>,
prefilter: bool,
ctx: &Context,
) -> Vec<Hit> {
let needle = prefilter.then_some(query.as_bytes());
let identity = crate::index::detect_identity(root).to_string();
let rows: Vec<SymbolRow> = crate::index::scan(root, skip, deadline, needle)
.into_iter()
.flat_map(|fs| fs.symbols)
.map(|s| SymbolRow::live(s, LIVE_REPO_ID, &identity))
.collect();
let rank = |q: &str| -> Vec<Hit> {
rows.iter()
.filter_map(|row| rank_one(q, row, Some(LIVE_REPO_ID), ctx.boosts(row, 0.0), false))
.collect()
};
let mut hits = rank(query);
if hits.is_empty()
&& let (leaf, Some(owner)) = score::parse_qualified(query)
&& leaf.eq_ignore_ascii_case("new")
{
hits = rank(owner);
constructor_owner(&mut hits);
}
sort_and_truncate(&mut hits, limit);
hits
}
pub(crate) fn merge(a: Vec<Hit>, b: Vec<Hit>, limit: usize) -> Vec<Hit> {
use std::collections::HashMap;
let mut by_key: HashMap<(String, i64, String), Hit> = HashMap::new();
for hit in a.into_iter().chain(b) {
let key = (hit.file.clone(), hit.line, hit.name.clone());
match by_key.get(&key) {
Some(existing) if existing.score >= hit.score => {}
_ => {
by_key.insert(key, hit);
}
}
}
let mut hits: Vec<Hit> = by_key.into_values().collect();
sort_and_truncate(&mut hits, limit);
hits
}
pub(crate) fn apply_scope_gate(query: &str, hits: &mut Vec<Hit>) {
if score::parse_qualified(query).1.is_none() {
return; }
let in_scope = |h: &Hit| h.features.iter().any(|f| f.name == "parent");
if hits.iter().any(in_scope) {
hits.retain(in_scope);
}
}
fn sort_and_truncate(hits: &mut Vec<Hit>, limit: usize) -> usize {
hits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.name.len().cmp(&b.name.len()))
.then_with(|| a.name.cmp(&b.name))
.then_with(|| (&a.file, a.line).cmp(&(&b.file, b.line)))
});
collapse_declarations(hits);
let total = hits.len();
hits.truncate(limit);
total
}
fn collapse_declarations(hits: &mut Vec<Hit>) {
use std::collections::HashMap;
let mut first: HashMap<(String, String, String, String), usize> = HashMap::new();
let mut folded: Vec<Vec<String>> = vec![Vec::new(); hits.len()];
let mut keep = Vec::with_capacity(hits.len());
for (i, hit) in hits.iter().enumerate() {
let Some(parent) = hit.parent.clone() else {
keep.push(true);
continue;
};
let key = (
hit.repo_identity.clone(),
parent,
hit.name.clone(),
hit.kind.clone(),
);
match first.get(&key) {
Some(&at) => {
folded[at].push(format!("{}:{}", hit.file, hit.line));
keep.push(false);
}
None => {
first.insert(key, i);
keep.push(true);
}
}
}
let mut i = 0;
hits.retain(|_| {
let k = keep[i];
i += 1;
k
});
let mut survivors = keep.iter().enumerate().filter(|(_, k)| **k).map(|(i, _)| i);
for hit in hits.iter_mut() {
let Some(src) = survivors.next() else { break };
if !folded[src].is_empty() {
hit.declarations = 1 + folded[src].len();
hit.also_in = std::mem::take(&mut folded[src]);
}
}
}
fn rank_one(
query: &str,
c: &SymbolRow,
current_repo_id: Option<i64>,
boosts: Boosts,
near_miss: bool,
) -> Option<Hit> {
let scored = score::score(query, c, current_repo_id, boosts, near_miss)?;
Some(Hit {
name: c.name.clone(),
kind: c.kind.clone(),
language: c.language.clone(),
file: c.file.clone(),
root: None,
line: c.line,
end_line: c.end_line,
parent: c.parent.clone(),
visibility: c.visibility.clone(),
repo_identity: c.repo_identity.clone(),
score: scored.total,
confidence: 0.0, features: scored.features,
signature: None,
body: None,
declarations: 1,
also_in: Vec::new(),
total: 0, explain: None,
})
}
#[cfg(test)]
mod tests {
#[test]
fn identical_names_rank_in_a_stable_order() {
let hit = |file: &str, line: i64| Hit {
name: "Transaction".into(),
kind: "class".into(),
language: "ruby".into(),
file: file.into(),
root: None,
line,
end_line: None,
parent: None,
visibility: None,
score: 1.0,
confidence: 0.5,
signature: None,
repo_identity: "local:/tmp/x".into(),
features: Vec::new(),
body: None,
declarations: 1,
also_in: Vec::new(),
total: 0,
explain: None,
};
let ordered = |mut hits: Vec<Hit>| {
sort_and_truncate(&mut hits, 10);
hits.into_iter()
.map(|h| (h.file, h.line))
.collect::<Vec<_>>()
};
let a = ordered(vec![
hit("app/models/b.rb", 1),
hit("app/models/a.rb", 9),
hit("app/models/a.rb", 2),
]);
let b = ordered(vec![
hit("app/models/a.rb", 2),
hit("app/models/b.rb", 1),
hit("app/models/a.rb", 9),
]);
assert_eq!(a, b, "ranking must not depend on row order");
assert_eq!(
a,
vec![
("app/models/a.rb".to_string(), 2),
("app/models/a.rb".to_string(), 9),
("app/models/b.rb".to_string(), 1),
]
);
}
use super::*;
use crate::core::{Kind, Symbol};
fn sym(name: &str, kind: Kind) -> Symbol {
Symbol {
name: name.into(),
kind,
language: "ruby".into(),
file: "app/x.rb".into(),
line: 1,
end_line: 1,
parent: None,
visibility: None,
}
}
fn store_with(symbols: &[Symbol]) -> Store {
let mut store = Store::open_in_memory().unwrap();
let repo = store
.upsert_repository(&crate::core::RepoIdentity::local("/tmp/x"), None)
.unwrap();
store
.replace_file_symbols(repo, "app/x.rb", "ruby", None, "h", symbols)
.unwrap();
store
}
#[test]
fn the_recall_filter_keeps_everything_the_scorer_would_accept() {
let mut store = Store::open_in_memory().unwrap();
let repo = store
.upsert_repository(&crate::core::RepoIdentity::local("/tmp/x"), None)
.unwrap();
let files = [
(
"lib/connection_pool.rb",
vec![
sym("ConnectionPool", Kind::Class),
sym("Base", Kind::Module),
sym("checkout", Kind::Method),
],
),
(
"lib/widget_controller.rb",
vec![sym("Widgets", Kind::Class), sym("render", Kind::Method)],
),
(
"lib/user.rb",
vec![
sym("User", Kind::Class),
sym("_private_user", Kind::Method),
sym("Überuser", Kind::Class),
sym("select", Kind::Method),
sym("Scheduler", Kind::Class),
sym("consolidate_all", Kind::Method),
],
),
];
for (file, syms) in &files {
store
.replace_file_symbols(repo, file, "ruby", None, "h", syms)
.unwrap();
}
let boosts = Boosts::default;
let queries = [
"conpool",
"usr",
"sleect",
"widgetcontroller",
"con*pool",
"Base::chckout",
"Über",
"privuser",
"zzz",
];
for query in queries {
let leaf = score::parse_qualified(query).0;
let recall = score::strip_wildcards(leaf);
let all = store
.search_candidates(&recall, 1000, true, None, None)
.unwrap();
let kept = store
.search_candidates(&recall, 1000, true, None, Some(could_match(query, &recall)))
.unwrap();
for near_miss in [false, true] {
let accepted =
|cands: &[SymbolRow]| -> std::collections::BTreeSet<(String, String)> {
cands
.iter()
.filter(|c| score::score(query, c, None, boosts(), near_miss).is_some())
.map(|c| (c.file.clone(), c.name.clone()))
.collect()
};
assert_eq!(
accepted(&all),
accepted(&kept),
"{query} (near miss: {near_miss})"
);
}
if query == "conpool" {
assert!(kept.len() < all.len(), "the filter narrows recall");
}
}
}
fn names(hits: &[Hit]) -> Vec<&str> {
hits.iter().map(|h| h.name.as_str()).collect()
}
fn store_two_repos() -> (Store, i64, i64) {
let mut store = Store::open_in_memory().unwrap();
let a = store
.upsert_repository(&crate::core::RepoIdentity::local("/tmp/a"), None)
.unwrap();
let b = store
.upsert_repository(&crate::core::RepoIdentity::local("/tmp/b"), None)
.unwrap();
store
.replace_file_symbols(a, "a.rb", "ruby", None, "h", &[sym("Widget", Kind::Class)])
.unwrap();
store
.replace_file_symbols(b, "b.rb", "ruby", None, "h", &[sym("Widget", Kind::Class)])
.unwrap();
(store, a, b)
}
#[test]
fn only_repo_scopes_results_to_that_repo() {
let (store, a, b) = store_two_repos();
let hits = search(&store, "Widget", Some(a), Some(a), &Context::default(), 10).unwrap();
assert_eq!(hits.hits.len(), 1);
assert_eq!(hits.hits[0].repo_identity, "local:/tmp/a");
let all = search(&store, "Widget", Some(a), None, &Context::default(), 10).unwrap();
assert_eq!(all.hits.len(), 2);
let _ = b;
}
#[test]
fn scoped_search_reports_no_match_rather_than_leaking_another_repo() {
let (store, a, _b) = store_two_repos();
let hits = search(&store, "Gadget", Some(a), Some(a), &Context::default(), 10).unwrap();
assert!(hits.is_empty());
}
#[test]
fn ranks_exact_match_first() {
let store = store_with(&[
sym("Users", Kind::Class),
sym("User", Kind::Class),
sym("UserMailer", Kind::Class),
]);
let hits = search(&store, "user", None, None, &Context::default(), 10).unwrap();
assert_eq!(hits[0].name, "User");
}
#[test]
fn abbreviation_finds_the_intended_symbol() {
let store = store_with(&[
sym("RefundProcessor", Kind::Class),
sym("Refund", Kind::Class),
sym("Payment", Kind::Class),
]);
let hits = search(&store, "refundproc", None, None, &Context::default(), 10).unwrap();
assert_eq!(hits[0].name, "RefundProcessor");
assert!(!names(&hits).contains(&"Payment"));
}
#[test]
fn short_fuzzy_query_still_resolves() {
let store = store_with(&[sym("User", Kind::Class), sym("Account", Kind::Class)]);
let hits = search(&store, "usr", None, None, &Context::default(), 10).unwrap();
assert_eq!(hits[0].name, "User");
}
#[test]
fn no_match_returns_empty() {
let store = store_with(&[sym("User", Kind::Class)]);
let hits = search(&store, "zzzzz", None, None, &Context::default(), 10).unwrap();
assert!(hits.is_empty());
}
#[test]
fn merge_dedups_by_location_keeping_higher_score() {
let mk = |name: &str, score: f64| Hit {
name: name.into(),
kind: "class".into(),
language: "ruby".into(),
file: "a.rb".into(),
root: None,
line: 1,
end_line: Some(1),
parent: None,
visibility: None,
repo_identity: "r".into(),
score,
confidence: 0.0,
features: vec![],
signature: None,
body: None,
declarations: 1,
also_in: Vec::new(),
total: 0,
explain: None,
};
let from_index = vec![mk("User", 100.0)];
let from_live = vec![mk("User", 500.0), mk("Account", 200.0)];
let merged = merge(from_index, from_live, 10);
assert_eq!(merged.len(), 2, "the duplicate User is collapsed");
assert_eq!(merged[0].name, "User");
assert_eq!(merged[0].score, 500.0, "the higher-scored duplicate wins");
}
#[test]
fn active_files_boosts_the_file_and_its_neighbors() {
let active = ActiveFiles::new(["app/services/refund.rb".to_string()]);
assert_eq!(active.boost("app/services/refund.rb"), BRANCH_FILE_BOOST);
assert_eq!(active.boost("app/services/charge.rb"), BRANCH_DIR_BOOST);
assert_eq!(active.boost("app/models/user.rb"), 0.0);
}
fn nested(name: &str, kind: Kind, parent: &str) -> Symbol {
Symbol {
parent: Some(parent.into()),
..sym(name, kind)
}
}
#[test]
fn qualified_query_ranks_the_definition_in_the_named_scope() {
let store = store_with(&[
nested("Config", Kind::Class, "Baz"),
nested("Config", Kind::Class, "Foo"),
nested("Config", Kind::Class, "Qux"),
]);
let hits = search(&store, "Foo::Config", None, None, &Context::default(), 10).unwrap();
assert_eq!(hits[0].parent.as_deref(), Some("Foo"));
assert!(hits[0].features.iter().any(|f| f.name == "parent"));
}
#[test]
fn qualifier_resolves_modules_and_methods_too() {
let store = store_with(&[
nested("perform", Kind::Method, "Bar::Worker"),
nested("perform", Kind::Method, "Other::Worker"),
nested("Worker", Kind::Module, "Bar"),
]);
let m = search(
&store,
"Bar::Worker#perform",
None,
None,
&Context::default(),
10,
)
.unwrap();
assert_eq!(m[0].kind, "method");
assert_eq!(m[0].parent.as_deref(), Some("Bar::Worker"));
let w = search(&store, "Bar::Worker", None, None, &Context::default(), 10).unwrap();
assert_eq!(w[0].name, "Worker");
assert_eq!(w[0].parent.as_deref(), Some("Bar"));
}
fn hit(name: &str, in_scope: bool) -> Hit {
Hit {
name: name.into(),
kind: "method".into(),
language: "ruby".into(),
file: "a.rb".into(),
root: None,
line: 1,
end_line: Some(1),
parent: None,
visibility: None,
repo_identity: "r".into(),
score: 1.0,
confidence: 0.0,
features: if in_scope {
vec![Feature {
name: "parent",
value: 180.0,
}]
} else {
vec![]
},
signature: None,
body: None,
declarations: 1,
also_in: Vec::new(),
total: 0,
explain: None,
}
}
#[test]
fn scope_gate_keeps_only_in_scope_results_when_some_match() {
let mut hits = vec![hit("baz", true), hit("baz", false), hit("baz", false)];
apply_scope_gate("Foo::Bar#baz", &mut hits);
assert_eq!(hits.len(), 1, "out-of-scope baz methods are dropped");
assert!(hits[0].features.iter().any(|f| f.name == "parent"));
}
#[test]
fn scope_gate_falls_back_when_nothing_matches_the_scope() {
let mut hits = vec![hit("baz", false), hit("baz", false)];
apply_scope_gate("Foo::Bar#baz", &mut hits);
assert_eq!(hits.len(), 2, "fall back rather than return empty");
}
#[test]
fn scope_gate_is_a_noop_for_an_unqualified_query() {
let mut hits = vec![hit("baz", true), hit("baz", false)];
apply_scope_gate("baz", &mut hits);
assert_eq!(hits.len(), 2, "no qualifier — nothing to gate on");
}
fn row(name: &str, parent: Option<&str>, file: &str, span: (i64, i64)) -> SymbolRow {
SymbolRow {
name: name.into(),
kind: "method".into(),
language: "ruby".into(),
file: file.into(),
line: span.0,
end_line: Some(span.1),
parent: parent.map(str::to_string),
repository_id: 1,
repo_identity: "local:/tmp/x".into(),
mtime: None,
git_ts: None,
visibility: None,
}
}
fn anchor_in_widget(line: i64) -> Anchor {
let defs = [
row("Shop", None, "app/shop/widget.rb", (1, 20)),
row("Widget", Some("Shop"), "app/shop/widget.rb", (2, 19)),
row(
"persist",
Some("Shop::Widget"),
"app/shop/widget.rb",
(5, 9),
),
];
Anchor::new(
"local:/tmp/x".into(),
"app/shop/widget.rb".into(),
line,
&defs,
)
}
#[test]
fn an_anchor_takes_the_innermost_enclosing_definition() {
assert_eq!(anchor_in_widget(7).scope, ["shop", "widget", "persist"]);
assert_eq!(anchor_in_widget(3).scope, ["shop", "widget"]);
assert!(anchor_in_widget(40).scope.is_empty(), "outside everything");
}
#[test]
fn enclosing_grades_by_how_much_scope_is_shared() {
let a = anchor_in_widget(7);
let cases = [
(Some("Shop::Widget"), 2.0 * ENCLOSING_STEP),
(Some("Shop"), ENCLOSING_STEP),
(Some("Shop::Gadget"), 0.0),
(Some("Other::Shop::Widget"), 0.0),
(Some("Shop::Widget::persist::Inner::Deeper"), 0.0),
(None, 0.0),
];
for (parent, want) in cases {
assert_eq!(a.enclosing(parent), want, "{parent:?}");
}
}
#[test]
fn proximity_decays_with_directory_distance() {
let a = anchor_in_widget(7);
let id = "local:/tmp/x";
let cases = [
(id, "app/shop/widget.rb", SAME_FILE_BOOST),
(id, "app/shop/gadget.rb", SAME_DIR_BOOST),
(id, "app/shop/parts/gear.rb", SAME_DIR_BOOST / 2.0),
(id, "app/other/gear.rb", SAME_DIR_BOOST / 4.0),
(id, "lib/deep/down/gear.rb", 0.0),
("local:/tmp/elsewhere", "app/shop/widget.rb", 0.0),
];
for (identity, file, want) in cases {
assert_eq!(a.proximity(identity, file), want, "{file}");
}
}
#[test]
fn an_anchor_prefers_the_definition_in_its_enclosing_class() {
let mut store = Store::open_in_memory().unwrap();
let repo = store
.upsert_repository(&crate::core::RepoIdentity::local("/tmp/x"), None)
.unwrap();
for (file, parent) in [
("app/a/gadget.rb", "Gadget"),
("app/shop/widget.rb", "Shop::Widget"),
("lib/z/zeta.rb", "Zeta"),
] {
store
.replace_file_symbols(
repo,
file,
"ruby",
None,
"h",
&[nested("save", Kind::Method, parent)],
)
.unwrap();
}
let plain = search(&store, "save", None, None, &Context::default(), 10).unwrap();
assert_eq!(
plain[0].parent.as_deref(),
Some("Gadget"),
"unanchored: by path"
);
assert!(plain.iter().all(|h| {
h.features
.iter()
.all(|f| !matches!(f.name, "enclosing" | "proximity"))
}));
let mut anchor = anchor_in_widget(7);
anchor.file = "app/b/other.rb".into();
let ctx = Context {
anchor: Some(anchor),
..Context::default()
};
let hits = search(&store, "save", None, None, &ctx, 10).unwrap();
assert_eq!(hits[0].parent.as_deref(), Some("Shop::Widget"));
assert!(hits[0].features.iter().any(|f| f.name == "enclosing"));
}
#[test]
fn branch_boost_lifts_an_active_file() {
let store = store_with(&[sym("User", Kind::Class)]); let ctx = Context {
active: ActiveFiles::new(["app/x.rb".to_string()]),
anchor: None,
};
let hits = search(&store, "user", None, None, &ctx, 10).unwrap();
assert!(hits[0].features.iter().any(|f| f.name == "branch"));
}
}