1mod score;
9
10pub use score::{Boosts, Feature, Scored, match_positions};
11
12use std::collections::{HashMap, HashSet};
13use std::path::Path;
14use std::time::{Instant, SystemTime, UNIX_EPOCH};
15
16use crate::store::{Store, SymbolRow};
17
18const CANDIDATE_LIMIT: usize = 8000;
23
24const LIVE_REPO_ID: i64 = -1;
27
28const BRANCH_FILE_BOOST: f64 = 180.0;
30const BRANCH_DIR_BOOST: f64 = 60.0;
32
33#[derive(Debug, Default, Clone)]
37pub struct ActiveFiles {
38 files: HashSet<String>,
39 dirs: HashSet<String>,
40}
41
42impl ActiveFiles {
43 pub fn new<I: IntoIterator<Item = String>>(paths: I) -> Self {
45 let files: HashSet<String> = paths.into_iter().collect();
46 let dirs = files
47 .iter()
48 .filter_map(|f| parent_dir(f))
49 .map(str::to_string)
50 .collect();
51 ActiveFiles { files, dirs }
52 }
53
54 fn is_empty(&self) -> bool {
55 self.files.is_empty()
56 }
57
58 fn boost(&self, path: &str) -> f64 {
61 if self.files.contains(path) {
62 BRANCH_FILE_BOOST
63 } else if parent_dir(path).is_some_and(|d| self.dirs.contains(d)) {
64 BRANCH_DIR_BOOST
65 } else {
66 0.0
67 }
68 }
69}
70
71fn parent_dir(path: &str) -> Option<&str> {
74 path.rfind('/').map(|i| &path[..i])
75}
76
77#[derive(Debug, Clone, PartialEq, serde::Serialize)]
79pub struct Hit {
80 pub name: String,
81 pub kind: String,
82 pub language: String,
83 pub file: String,
84 pub line: i64,
85 pub parent: Option<String>,
86 #[serde(rename = "repo")]
87 pub repo_identity: String,
88 pub score: f64,
89 pub features: Vec<Feature>,
90 pub signature: Option<String>,
93}
94
95pub fn search(
99 store: &Store,
100 query: &str,
101 current_repo_id: Option<i64>,
102 active: &ActiveFiles,
103 limit: usize,
104) -> crate::store::Result<Vec<Hit>> {
105 let stripped;
108 let recall = if score::has_wildcard(query) {
109 stripped = score::strip_wildcards(query);
110 stripped.as_str()
111 } else {
112 query
113 };
114 let trace_on = crate::trace::enabled();
115 let t = std::time::Instant::now();
116 let candidates =
117 store.search_candidates(recall, CANDIDATE_LIMIT, score::has_wildcard(query))?;
118 let n_candidates = candidates.len();
119 let t_recall = t.elapsed();
120 let t = std::time::Instant::now();
121 let learned = learned_boosts(store, query)?;
122 let now = now_unix();
123
124 let mut hits: Vec<Hit> = candidates
125 .into_iter()
126 .filter_map(|c| {
127 let key = (c.repository_id, c.file.clone(), c.name.clone());
128 let boosts = Boosts {
129 learned: learned.get(&key).copied().unwrap_or(0.0),
130 recency: recency_boost(c.git_ts.max(c.mtime), now),
133 branch: if active.is_empty() {
134 0.0
135 } else {
136 active.boost(&c.file)
137 },
138 };
139 rank_one(query, c, current_repo_id, boosts)
140 })
141 .collect();
142 let n_hits = hits.len();
143 let t_score = t.elapsed();
144
145 let t = std::time::Instant::now();
146 sort_and_truncate(&mut hits, limit);
147 if trace_on {
148 crate::trace!(
149 "search {query:?}: recall {n_candidates} cand in {} ms, score→{n_hits} hits in {} ms, sort {} ms",
150 t_recall.as_millis(),
151 t_score.as_millis(),
152 t.elapsed().as_millis(),
153 );
154 }
155 Ok(hits)
156}
157
158fn recency_boost(mtime: Option<i64>, now: i64) -> f64 {
161 let Some(mtime) = mtime else {
162 return 0.0;
163 };
164 let age_days = (now - mtime).max(0) as f64 / 86_400.0;
165 let boost = 120.0 * 0.5_f64.powf(age_days / 14.0);
166 if boost < 1.0 { 0.0 } else { boost }
167}
168
169fn learned_boosts(
171 store: &Store,
172 query: &str,
173) -> crate::store::Result<HashMap<(i64, String, String), f64>> {
174 let now = now_unix();
175 let q = query.to_ascii_lowercase();
176 let mut map: HashMap<(i64, String, String), f64> = HashMap::new();
177 for s in store.selections_for(&q)? {
178 let boost = learned_boost(s.selections, s.last_selected_at, now);
181 let entry = map.entry((s.repository_id, s.file, s.name)).or_insert(0.0);
182 *entry = entry.max(boost);
183 }
184 Ok(map)
185}
186
187fn learned_boost(selections: i64, last_selected_at: i64, now: i64) -> f64 {
191 if selections <= 0 {
192 return 0.0;
193 }
194 let strength = (selections.min(5) as f64) / 5.0;
195 let age_days = (now - last_selected_at).max(0) as f64 / 86_400.0;
196 let recency = 0.5_f64.powf(age_days / 30.0).max(0.25);
197 260.0 * strength * recency
198}
199
200fn now_unix() -> i64 {
201 SystemTime::now()
202 .duration_since(UNIX_EPOCH)
203 .map(|d| d.as_secs() as i64)
204 .unwrap_or(0)
205}
206
207pub fn live_search(
215 root: &Path,
216 query: &str,
217 limit: usize,
218 skip: &HashSet<String>,
219 deadline: Option<Instant>,
220 prefilter: bool,
221) -> Vec<Hit> {
222 let needle = prefilter.then_some(query.as_bytes());
223 let identity = crate::index::detect_identity(root).to_string();
224 let mut hits: Vec<Hit> = crate::index::scan(root, skip, deadline, needle)
225 .into_iter()
226 .flat_map(|fs| fs.symbols)
227 .filter_map(|s| {
228 let row = SymbolRow {
229 name: s.name,
230 kind: s.kind.as_str().to_string(),
231 language: s.language,
232 file: s.file,
233 line: s.line as i64,
234 parent: s.parent,
235 repository_id: LIVE_REPO_ID,
236 repo_identity: identity.clone(),
237 mtime: None,
238 git_ts: None,
239 };
240 rank_one(query, row, Some(LIVE_REPO_ID), Boosts::default())
241 })
242 .collect();
243 sort_and_truncate(&mut hits, limit);
244 hits
245}
246
247pub fn merge(a: Vec<Hit>, b: Vec<Hit>, limit: usize) -> Vec<Hit> {
251 use std::collections::HashMap;
252 let mut by_key: HashMap<(String, i64, String), Hit> = HashMap::new();
253 for hit in a.into_iter().chain(b) {
254 let key = (hit.file.clone(), hit.line, hit.name.clone());
255 match by_key.get(&key) {
256 Some(existing) if existing.score >= hit.score => {}
257 _ => {
258 by_key.insert(key, hit);
259 }
260 }
261 }
262 let mut hits: Vec<Hit> = by_key.into_values().collect();
263 sort_and_truncate(&mut hits, limit);
264 hits
265}
266
267fn sort_and_truncate(hits: &mut Vec<Hit>, limit: usize) {
269 hits.sort_by(|a, b| {
270 b.score
271 .partial_cmp(&a.score)
272 .unwrap_or(std::cmp::Ordering::Equal)
273 .then_with(|| a.name.len().cmp(&b.name.len()))
274 .then_with(|| a.name.cmp(&b.name))
275 });
276 hits.truncate(limit);
277}
278
279fn rank_one(
280 query: &str,
281 c: SymbolRow,
282 current_repo_id: Option<i64>,
283 boosts: Boosts,
284) -> Option<Hit> {
285 let scored = score::score(query, &c, current_repo_id, boosts)?;
286 Some(Hit {
287 name: c.name,
288 kind: c.kind,
289 language: c.language,
290 file: c.file,
291 line: c.line,
292 parent: c.parent,
293 repo_identity: c.repo_identity,
294 score: scored.total,
295 features: scored.features,
296 signature: None,
297 })
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::core::{Kind, Symbol};
304
305 fn sym(name: &str, kind: Kind) -> Symbol {
306 Symbol {
307 name: name.into(),
308 kind,
309 language: "ruby".into(),
310 file: "app/x.rb".into(),
311 line: 1,
312 parent: None,
313 }
314 }
315
316 fn store_with(symbols: &[Symbol]) -> Store {
317 let mut store = Store::open_in_memory().unwrap();
318 let repo = store
319 .upsert_repository(&crate::core::RepoIdentity::local("/tmp/x"), None)
320 .unwrap();
321 store
322 .replace_file_symbols(repo, "app/x.rb", "ruby", None, "h", symbols)
323 .unwrap();
324 store
325 }
326
327 fn names(hits: &[Hit]) -> Vec<&str> {
328 hits.iter().map(|h| h.name.as_str()).collect()
329 }
330
331 #[test]
332 fn ranks_exact_match_first() {
333 let store = store_with(&[
334 sym("Users", Kind::Class),
335 sym("User", Kind::Class),
336 sym("UserMailer", Kind::Class),
337 ]);
338 let hits = search(&store, "user", None, &ActiveFiles::default(), 10).unwrap();
339 assert_eq!(hits[0].name, "User");
340 }
341
342 #[test]
343 fn abbreviation_finds_the_intended_symbol() {
344 let store = store_with(&[
345 sym("RefundProcessor", Kind::Class),
346 sym("Refund", Kind::Class),
347 sym("Payment", Kind::Class),
348 ]);
349 let hits = search(&store, "refundproc", None, &ActiveFiles::default(), 10).unwrap();
350 assert_eq!(hits[0].name, "RefundProcessor");
351 assert!(!names(&hits).contains(&"Payment"));
352 }
353
354 #[test]
355 fn short_fuzzy_query_still_resolves() {
356 let store = store_with(&[sym("User", Kind::Class), sym("Account", Kind::Class)]);
357 let hits = search(&store, "usr", None, &ActiveFiles::default(), 10).unwrap();
358 assert_eq!(hits[0].name, "User");
359 }
360
361 #[test]
362 fn no_match_returns_empty() {
363 let store = store_with(&[sym("User", Kind::Class)]);
364 let hits = search(&store, "zzzzz", None, &ActiveFiles::default(), 10).unwrap();
365 assert!(hits.is_empty());
366 }
367
368 #[test]
369 fn merge_dedups_by_location_keeping_higher_score() {
370 let mk = |name: &str, score: f64| Hit {
371 name: name.into(),
372 kind: "class".into(),
373 language: "ruby".into(),
374 file: "a.rb".into(),
375 line: 1,
376 parent: None,
377 repo_identity: "r".into(),
378 score,
379 features: vec![],
380 signature: None,
381 };
382 let from_index = vec![mk("User", 100.0)];
383 let from_live = vec![mk("User", 500.0), mk("Account", 200.0)];
384 let merged = merge(from_index, from_live, 10);
385 assert_eq!(merged.len(), 2, "the duplicate User is collapsed");
386 assert_eq!(merged[0].name, "User");
387 assert_eq!(merged[0].score, 500.0, "the higher-scored duplicate wins");
388 }
389
390 #[test]
391 fn active_files_boosts_the_file_and_its_neighbors() {
392 let active = ActiveFiles::new(["app/services/refund.rb".to_string()]);
393 assert_eq!(active.boost("app/services/refund.rb"), BRANCH_FILE_BOOST);
395 assert_eq!(active.boost("app/services/charge.rb"), BRANCH_DIR_BOOST);
397 assert_eq!(active.boost("app/models/user.rb"), 0.0);
399 }
400
401 #[test]
402 fn branch_boost_lifts_an_active_file() {
403 let store = store_with(&[sym("User", Kind::Class)]); let active = ActiveFiles::new(["app/x.rb".to_string()]);
405 let hits = search(&store, "user", None, &active, 10).unwrap();
406 assert!(hits[0].features.iter().any(|f| f.name == "branch"));
407 }
408}