1use std::collections::HashSet;
20
21use gitcortex_core::{error::Result, graph::Node, schema::NodeKind, store::GraphStore};
22use serde::Serialize;
23
24#[derive(Debug, Clone, Serialize)]
25pub struct SearchHit {
26 pub id: String,
27 pub name: String,
28 pub qualified_name: String,
29 pub kind: String,
30 pub file: String,
31 pub start_line: u32,
32 pub score: i32,
33}
34
35#[derive(Debug, Clone, Serialize)]
38pub struct FileGroup {
39 pub file: String,
40 pub symbol_count: usize,
41 pub top_symbols: Vec<String>,
43}
44
45pub fn group_by_file(hits: &[SearchHit]) -> Vec<FileGroup> {
51 let mut order: Vec<String> = Vec::new();
53 let mut map: std::collections::HashMap<String, (usize, Vec<String>)> =
54 std::collections::HashMap::new();
55
56 for h in hits {
57 let e = map.entry(h.file.clone()).or_insert_with(|| {
58 order.push(h.file.clone());
59 (0, Vec::new())
60 });
61 e.0 += 1;
62 if e.1.len() < 3 {
63 e.1.push(h.name.clone());
64 }
65 }
66
67 let mut groups: Vec<FileGroup> = order
68 .into_iter()
69 .map(|file| {
70 let (count, top) = map.remove(&file).unwrap();
71 FileGroup {
72 file,
73 symbol_count: count,
74 top_symbols: top,
75 }
76 })
77 .collect();
78
79 groups.sort_by(|a, b| {
80 b.symbol_count
81 .cmp(&a.symbol_count)
82 .then_with(|| a.file.cmp(&b.file))
83 });
84 groups
85}
86
87const DEFAULT_LIMIT: usize = 10;
88const MAX_LIMIT: usize = 200;
89const MIN_TOKEN_LEN: usize = 3;
90
91pub(crate) fn tokenize(s: &str) -> Vec<String> {
98 let mut tokens = Vec::new();
99 let mut current = String::new();
100 let chars: Vec<char> = s.chars().collect();
101 for (i, &ch) in chars.iter().enumerate() {
102 if ch == '_' || ch == '-' || ch == '.' || ch == ':' || ch == '/' || ch == ' ' {
103 if !current.is_empty() {
104 tokens.push(current.to_ascii_lowercase());
105 current = String::new();
106 }
107 } else if ch.is_uppercase() {
108 let next_is_lower = chars.get(i + 1).map(|c| c.is_lowercase()).unwrap_or(false);
111 let prev_is_upper = i > 0 && chars[i - 1].is_uppercase();
112 if !current.is_empty() && (!prev_is_upper || next_is_lower) {
113 tokens.push(current.to_ascii_lowercase());
114 current = String::new();
115 }
116 current.push(ch.to_ascii_lowercase());
117 } else {
118 current.push(ch);
119 }
120 }
121 if !current.is_empty() {
122 tokens.push(current.to_ascii_lowercase());
123 }
124 tokens
125}
126
127fn edit_distance(a: &str, b: &str) -> usize {
129 let a: Vec<char> = a.chars().collect();
130 let b: Vec<char> = b.chars().collect();
131 let m = a.len();
132 let n = b.len();
133 if m.abs_diff(n) > 3 {
135 return usize::MAX;
136 }
137 let mut prev: Vec<usize> = (0..=n).collect();
138 let mut curr = vec![0usize; n + 1];
139 for i in 1..=m {
140 curr[0] = i;
141 for j in 1..=n {
142 curr[j] = if a[i - 1] == b[j - 1] {
143 prev[j - 1]
144 } else {
145 1 + prev[j - 1].min(prev[j]).min(curr[j - 1])
146 };
147 }
148 std::mem::swap(&mut prev, &mut curr);
149 }
150 prev[n]
151}
152
153const EXACT_CASE_BONUS: i32 = 8;
160
161const TEST_FILE_PENALTY: i32 = -45;
167
168fn score(n: &Node, q_lower: &str, q_tokens: &[String], query: &str) -> Option<i32> {
169 if n.kind == NodeKind::Section {
173 return None;
174 }
175 let name_lower = n.name.to_ascii_lowercase();
176 let qname_lower = n.qualified_name.to_ascii_lowercase();
177 let name_tokens = tokenize(&n.name);
178
179 let base = if name_lower == q_lower {
180 100
182 } else if name_lower.starts_with(q_lower) {
183 60
184 } else if !q_tokens.is_empty() && q_tokens.iter().all(|t| name_tokens.contains(t)) {
185 50
188 } else if name_lower.contains(q_lower) {
189 30
190 } else {
191 let overlap = q_tokens
193 .iter()
194 .filter(|qt| qt.len() >= MIN_TOKEN_LEN && name_tokens.contains(*qt))
195 .count();
196 if overlap > 0 {
197 10 + (overlap as i32 * 5).min(15)
198 } else if qname_lower.contains(q_lower) {
199 10
201 } else if q_lower.len() >= 4 && q_lower.len() <= 15 && name_lower.len() <= 25 {
202 let dist = edit_distance(q_lower, &name_lower);
204 if dist <= 1 {
205 20
206 } else if dist <= 2 {
207 10
208 } else {
209 return None;
210 }
211 } else {
212 return None;
213 }
214 };
215
216 let exact_case = if n.name == query { EXACT_CASE_BONUS } else { 0 };
217 let test_penalty = if super::helpers::is_test_file(&n.file) {
218 TEST_FILE_PENALTY
219 } else {
220 0
221 };
222 Some(base + kind_boost(&n.kind) + exact_case + test_penalty)
223}
224
225fn kind_boost(k: &NodeKind) -> i32 {
226 match k {
227 NodeKind::Function | NodeKind::Method => 5,
228 NodeKind::Struct | NodeKind::Trait | NodeKind::Interface => 4,
229 NodeKind::Enum | NodeKind::TypeAlias => 3,
230 NodeKind::Constant | NodeKind::Macro | NodeKind::Annotation => 2,
231 NodeKind::File => -25,
237 NodeKind::Folder => -45,
238 _ => 0,
239 }
240}
241
242fn to_hit(n: Node, score: i32) -> SearchHit {
243 SearchHit {
244 id: n.id.as_str().to_owned(),
245 name: n.name,
246 qualified_name: n.qualified_name,
247 kind: n.kind.to_string(),
248 file: n.file.display().to_string(),
249 start_line: n.span.start_line,
250 score,
251 }
252}
253
254pub fn search<S: GraphStore + ?Sized>(
261 store: &S,
262 branch: &str,
263 query: &str,
264 limit: Option<usize>,
265) -> Result<Vec<SearchHit>> {
266 let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
267 let q = query.trim();
268 if q.is_empty() {
269 return Ok(Vec::new());
270 }
271
272 let q_lower = q.to_ascii_lowercase();
273 let q_tokens = tokenize(q);
274 let candidate_limit = (limit * 50).max(500);
275
276 let mut seen: HashSet<String> = HashSet::new();
278 let mut nodes: Vec<Node> = Vec::new();
279
280 let push = |nodes: &mut Vec<Node>, seen: &mut HashSet<String>, batch: Vec<Node>| {
281 for n in batch {
282 let id = n.id.as_str();
283 if seen.insert(id) {
284 nodes.push(n);
285 }
286 }
287 };
288
289 push(
290 &mut nodes,
291 &mut seen,
292 store.search_nodes(branch, q, candidate_limit)?,
293 );
294
295 for token in &q_tokens {
298 if token.len() < MIN_TOKEN_LEN {
299 continue;
300 }
301 if token.as_str() == q_lower {
303 continue;
304 }
305 push(
306 &mut nodes,
307 &mut seen,
308 store.search_nodes(branch, token, candidate_limit)?,
309 );
310 }
311
312 if nodes.is_empty() && q_lower.len() >= 4 && q_lower.len() <= 20 {
317 push(&mut nodes, &mut seen, store.list_all_nodes(branch)?);
318 }
319
320 let mut hits: Vec<SearchHit> = nodes
321 .into_iter()
322 .filter_map(|n| score(&n, &q_lower, &q_tokens, query).map(|s| to_hit(n, s)))
323 .collect();
324
325 hits.sort_by(|a, b| {
326 b.score
327 .cmp(&a.score)
328 .then_with(|| a.name.len().cmp(&b.name.len()))
329 .then_with(|| a.qualified_name.cmp(&b.qualified_name))
330 });
331 hits.truncate(limit);
332 Ok(hits)
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use gitcortex_core::graph::{NodeId, NodeMetadata, Span};
339 use std::path::PathBuf;
340
341 fn node_of(kind: NodeKind, name: &str) -> Node {
342 Node {
343 id: NodeId::new(),
344 kind,
345 name: name.to_owned(),
346 qualified_name: name.to_owned(),
347 file: PathBuf::from("src/lib.rs"),
348 span: Span {
349 start_line: 1,
350 end_line: 2,
351 },
352 metadata: NodeMetadata::default(),
353 }
354 }
355
356 fn score_of(kind: NodeKind, name: &str, query: &str) -> Option<i32> {
358 let q_lower = query.to_ascii_lowercase();
359 let q_tokens = tokenize(query);
360 score(&node_of(kind, name), &q_lower, &q_tokens, query)
361 }
362
363 fn score_at(kind: NodeKind, name: &str, file: &str, query: &str) -> Option<i32> {
365 let q_lower = query.to_ascii_lowercase();
366 let q_tokens = tokenize(query);
367 let mut n = node_of(kind, name);
368 n.file = PathBuf::from(file);
369 score(&n, &q_lower, &q_tokens, query)
370 }
371
372 #[test]
375 fn case_exact_definition_outranks_case_insensitive_method() {
376 let struct_hit = score_of(NodeKind::Struct, "Searcher", "Searcher").unwrap();
379 let method_hit = score_of(NodeKind::Method, "searcher", "Searcher").unwrap();
380 assert!(
381 struct_hit > method_hit,
382 "case-exact struct {struct_hit} must outrank case-insensitive method {method_hit}"
383 );
384 }
385
386 #[test]
387 fn case_insensitive_match_still_scores() {
388 assert!(score_of(NodeKind::Method, "searcher", "Searcher").is_some());
389 }
390
391 #[test]
392 fn folder_ranks_below_a_weaker_code_match() {
393 let folder = score_of(NodeKind::Folder, "searcher", "Searcher").unwrap();
396 let prefix_struct = score_of(NodeKind::Struct, "SearcherBuilder", "Searcher").unwrap();
397 assert!(
398 folder < prefix_struct,
399 "folder {folder} must rank below prefix-matched struct {prefix_struct}"
400 );
401 }
402
403 #[test]
404 fn file_ranks_below_a_definition_of_the_same_name() {
405 let file = score_of(NodeKind::File, "Searcher", "Searcher").unwrap();
406 let definition = score_of(NodeKind::Struct, "Searcher", "Searcher").unwrap();
407 assert!(
408 file < definition,
409 "file {file} must rank below struct {definition}"
410 );
411 }
412
413 #[test]
414 fn file_is_still_findable_by_its_own_name() {
415 assert!(score_of(NodeKind::File, "sessions.py", "sessions.py").is_some());
417 }
418
419 #[test]
420 fn test_symbols_rank_below_production_symbols() {
421 let test_exact = score_at(
424 NodeKind::Method,
425 "jsonReader",
426 "src/test/java/NumberLimitsTest.java",
427 "JsonReader",
428 )
429 .unwrap();
430 let prod_prefix = score_at(
431 NodeKind::Struct,
432 "JsonReaderInternal",
433 "src/main/java/JsonReaderInternal.java",
434 "JsonReader",
435 )
436 .unwrap();
437 assert!(
438 test_exact < prod_prefix,
439 "exact-match test symbol {test_exact} must rank below production prefix match {prod_prefix}"
440 );
441 }
442
443 #[test]
444 fn test_support_modules_rank_below_production() {
445 let helper = score_at(
448 NodeKind::Struct,
449 "SearcherTester",
450 "crates/searcher/src/testutil.rs",
451 "Searcher",
452 )
453 .unwrap();
454 let production = score_at(
455 NodeKind::Struct,
456 "SearcherBuilder",
457 "crates/searcher/src/searcher/mod.rs",
458 "Searcher",
459 )
460 .unwrap();
461 assert!(
462 helper < production,
463 "test-support struct {helper} must rank below production struct {production}"
464 );
465 }
466
467 #[test]
468 fn test_symbols_are_still_findable() {
469 assert!(score_at(
471 NodeKind::Struct,
472 "JsonReaderTest",
473 "src/test/java/JsonReaderTest.java",
474 "JsonReaderTest"
475 )
476 .is_some());
477 }
478
479 #[test]
480 fn markdown_sections_are_excluded_from_code_search() {
481 assert_eq!(score_of(NodeKind::Section, "Searcher", "Searcher"), None);
484 }
485
486 #[test]
487 fn tokenize_camel_case() {
488 assert_eq!(tokenize("AuthConfig"), vec!["auth", "config"]);
489 assert_eq!(tokenize("validateToken"), vec!["validate", "token"]);
490 assert_eq!(tokenize("HTTPClient"), vec!["http", "client"]);
491 }
492
493 #[test]
494 fn tokenize_snake_case() {
495 assert_eq!(tokenize("validate_token"), vec!["validate", "token"]);
496 assert_eq!(tokenize("auth_middleware"), vec!["auth", "middleware"]);
497 }
498
499 #[test]
500 fn tokenize_pascal_case() {
501 assert_eq!(tokenize("KuzuGraphStore"), vec!["kuzu", "graph", "store"]);
502 }
503
504 #[test]
505 fn edit_distance_exact() {
506 assert_eq!(edit_distance("validate", "validate"), 0);
507 }
508
509 #[test]
510 fn edit_distance_typo() {
511 assert_eq!(edit_distance("vlidate", "validate"), 1);
512 assert_eq!(edit_distance("authnticate", "authenticate"), 1);
513 }
514
515 #[test]
516 fn edit_distance_length_short_circuit() {
517 assert_eq!(edit_distance("a", "abcde"), usize::MAX);
519 }
520
521 fn hit(name: &str, file: &str, score: i32) -> SearchHit {
524 SearchHit {
525 id: String::new(),
526 name: name.to_owned(),
527 qualified_name: name.to_owned(),
528 kind: "Function".to_owned(),
529 file: file.to_owned(),
530 start_line: 1,
531 score,
532 }
533 }
534
535 #[test]
536 fn group_by_file_empty_returns_empty() {
537 assert!(group_by_file(&[]).is_empty());
538 }
539
540 #[test]
541 fn group_by_file_single_file() {
542 let hits = vec![hit("parse_args", "src/parser.rs", 100)];
543 let groups = group_by_file(&hits);
544 assert_eq!(groups.len(), 1);
545 assert_eq!(groups[0].file, "src/parser.rs");
546 assert_eq!(groups[0].symbol_count, 1);
547 assert_eq!(groups[0].top_symbols, vec!["parse_args"]);
548 }
549
550 #[test]
551 fn group_by_file_sorted_by_count_desc() {
552 let hits = vec![
553 hit("a", "src/big.rs", 80),
554 hit("b", "src/big.rs", 70),
555 hit("c", "src/big.rs", 60),
556 hit("x", "src/small.rs", 50),
557 ];
558 let groups = group_by_file(&hits);
559 assert_eq!(groups[0].file, "src/big.rs");
560 assert_eq!(groups[0].symbol_count, 3);
561 assert_eq!(groups[1].file, "src/small.rs");
562 assert_eq!(groups[1].symbol_count, 1);
563 }
564
565 #[test]
566 fn group_by_file_top_symbols_capped_at_three() {
567 let hits: Vec<SearchHit> = (0..8)
568 .map(|i| hit(&format!("fn{i}"), "src/big.rs", 100 - i))
569 .collect();
570 let groups = group_by_file(&hits);
571 assert_eq!(groups[0].symbol_count, 8);
572 assert_eq!(groups[0].top_symbols.len(), 3);
573 assert_eq!(groups[0].top_symbols[0], "fn0");
575 }
576
577 #[test]
578 fn group_by_file_alphabetical_tie_break() {
579 let hits = vec![hit("a", "src/z.rs", 50), hit("b", "src/a.rs", 50)];
580 let groups = group_by_file(&hits);
581 assert_eq!(groups[0].file, "src/a.rs");
583 }
584}