Skip to main content

gitcortex_mcp/mcp/
search.rs

1//! Fuzzy search over the graph — multi-signal ranking with CamelCase/snake_case
2//! tokenisation, token overlap scoring, and edit-distance typo tolerance.
3//!
4//! Ranking signals (higher score = better match):
5//! - exact name match:                   +100
6//! - prefix name match:                  +60
7//! - all query tokens match name tokens: +50
8//! - substring in name:                  +30
9//! - partial token overlap:              +10..+25
10//! - edit distance ≤1 (typo):            +20
11//! - edit distance ≤2:                   +10
12//! - substring in qualified_name only:   +10
13//! - byte-exact (case-sensitive) name:   +8 on top of the base
14//! - shorter names break ties
15//! - kind boost: Function/Method/Struct/Trait > others
16//! - kind penalty: File -25, Folder -45 — containers are paths, not definitions
17//! - Markdown `Section` headings are excluded from code search entirely
18
19use 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/// Hits grouped by file — lets a model see "parse is in parser.rs (8 symbols)"
36/// at a glance without iterating the full flat hit list.
37#[derive(Debug, Clone, Serialize)]
38pub struct FileGroup {
39    pub file: String,
40    pub symbol_count: usize,
41    /// Up to 3 highest-scoring symbol names in this file.
42    pub top_symbols: Vec<String>,
43}
44
45/// Group search hits by file. Groups are sorted by `symbol_count` descending
46/// (most hits first), ties broken alphabetically by file path.
47///
48/// Input must already be sorted by score descending so `top_symbols` picks the
49/// highest-scoring symbols per file without extra sorting.
50pub fn group_by_file(hits: &[SearchHit]) -> Vec<FileGroup> {
51    // Preserve insertion order (first-seen = highest-scored) per file.
52    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
91/// Split a camelCase/snake_case/PascalCase identifier into lowercase tokens.
92///
93/// "AuthConfig"      → ["auth", "config"]
94/// "validate_token"  → ["validate", "token"]
95/// "parseJSONResponse" → ["parse", "j", "s", "o", "n", "response"]  (intentional — acronyms split per char)
96/// "HTTPClient"      → ["h", "t", "t", "p", "client"]
97pub(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            // Start new token on uppercase — but keep run of capitals together
109            // as one token (e.g. "HTTP" stays "http" not split per char).
110            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
127/// Levenshtein edit distance between two strings (capped early at `max`).
128fn 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    // Quick bounds: length difference alone is a lower bound.
134    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
153/// Score a node against a query. Returns `None` when the node is not a match.
154/// Bonus for a byte-exact name match, on top of the case-insensitive base.
155///
156/// Without it, `Searcher` and `searcher` tie on base score and the winner is
157/// decided by kind boost alone — which ranked `HiArgs::searcher` (Method, +5)
158/// above the `Searcher` struct (+4). Must exceed the largest kind-boost gap.
159const EXACT_CASE_BONUS: i32 = 8;
160
161/// Penalty for symbols defined in test files.
162///
163/// Production evidence comes first: an exactly-named test helper must not
164/// outrank a real definition. Sized to drop the exact-match band (100) below
165/// the prefix-match band (60), so tests stay findable but never lead.
166const TEST_FILE_PENALTY: i32 = -45;
167
168fn score(n: &Node, q_lower: &str, q_tokens: &[String], query: &str) -> Option<i32> {
169    // Markdown headings are prose, not definitions. A README section named
170    // after a symbol is never the answer to a code search, and lookup_symbol
171    // and get_subgraph already exclude them.
172    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        // Exact name match — highest confidence.
181        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        // All query tokens present in name tokens.
186        // "auth config" fully matches "AuthConfig" or "auth_config".
187        50
188    } else if name_lower.contains(q_lower) {
189        30
190    } else {
191        // Partial token overlap.
192        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            // Match only in qualified path (e.g. module prefix).
200            10
201        } else if q_lower.len() >= 4 && q_lower.len() <= 15 && name_lower.len() <= 25 {
202            // Typo tolerance: edit distance on short-ish queries.
203            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        // Containers are paths, not definitions. A folder or file whose name
232        // happens to match should stay findable but must never outrank a real
233        // definition — ripgrep's `crates/searcher` folder was landing above
234        // `SearcherBuilder`. The penalties clear the strongest weaker-match
235        // band (prefix, 60) from the exact-match band (100).
236        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
254/// Run a fuzzy search across all nodes on `branch`.
255///
256/// Candidate set is built by querying the store for the whole query string AND
257/// for each individual token (for multi-word / camelCase queries). Candidates
258/// are deduplicated, scored with the multi-signal scorer, sorted by score
259/// descending, and truncated to `limit`.
260pub 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    // Fetch candidates: whole query first, then per token.
277    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    // Per-token expansion: lets "validate token" find "validate_token" even
296    // when the store's CONTAINS filter requires the full substring.
297    for token in &q_tokens {
298        if token.len() < MIN_TOKEN_LEN {
299            continue;
300        }
301        // Skip if token equals the whole query (already fetched above).
302        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    // Typo-fallback: CONTAINS can't find misspelled queries ("Greetter" won't
313    // match "Greeter"). When no candidates found and query is short enough for
314    // edit-distance to be meaningful, scan all nodes so the scorer can apply
315    // typo tolerance.
316    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    /// Score a node against a query the way `search` does.
357    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    /// Score a node that lives at an explicit path.
364    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    // ── ranking defects found by the relevance gate ──────────────────────────
373
374    #[test]
375    fn case_exact_definition_outranks_case_insensitive_method() {
376        // ripgrep: `HiArgs::searcher` (Method) scored 105 and buried the
377        // `Searcher` struct at 104 purely on the Method kind boost.
378        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        // `crates/searcher` (Folder, exact name match) outranked
394        // `SearcherBuilder` (Struct, prefix match) in the measured baseline.
395        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        // Demotion must not remove files from search entirely.
416        assert!(score_of(NodeKind::File, "sessions.py", "sessions.py").is_some());
417    }
418
419    #[test]
420    fn test_symbols_rank_below_production_symbols() {
421        // gson put two src/test/ classes in the top five, and requests put a
422        // test method at rank 5. The plan requires production evidence first.
423        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        // ripgrep ships `crates/searcher/src/testutil.rs`; its `SearcherTester`
446        // tied `SearcherBuilder` and won on the shorter-name tie-break.
447        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        // Demoted, never dropped: searching for a test by name must still work.
470        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        // A README heading named after a symbol is prose, not a definition.
482        // lookup_symbol and get_subgraph already filter Section; search did not.
483        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        // length difference > 3 → MAX
518        assert_eq!(edit_distance("a", "abcde"), usize::MAX);
519    }
520
521    // ── group_by_file ─────────────────────────────────────────────────────────
522
523    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        // First three are highest-scored (fn0, fn1, fn2).
574        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        // Both have count=1; alphabetical tie-break: a.rs before z.rs.
582        assert_eq!(groups[0].file, "src/a.rs");
583    }
584}