Skip to main content

krait/index/
cache_query.rs

1//! Cache-first query path: serve queries from the `SQLite` index without LSP.
2//!
3//! All functions are sync — no LSP dependency.
4
5use std::collections::HashMap;
6use std::path::Path;
7
8use serde_json::{json, Value};
9
10use crate::commands::{
11    find::SymbolMatch, list::SymbolEntry, read::format_numbered_lines, DEFAULT_MAX_LINES,
12};
13use crate::index::hasher;
14use crate::index::store::{CachedSymbol, IndexStore};
15use crate::index::watcher::DirtyFiles;
16
17/// Check whether a file's current content matches the index.
18///
19/// When a `DirtyFiles` watcher is active:
20/// - Dirty files → return false immediately (O(1) set lookup)
21/// - Clean files → trust the index (just check file exists in DB)
22///
23/// Without a watcher, falls back to full BLAKE3 hash comparison.
24fn is_file_fresh(
25    store: &IndexStore,
26    rel_path: &str,
27    project_root: &Path,
28    dirty_files: Option<&DirtyFiles>,
29) -> bool {
30    if let Some(df) = dirty_files {
31        // Watcher active: use dirty set instead of hashing
32        if df.is_dirty(rel_path) {
33            return false;
34        }
35        // Not dirty — trust the index, just verify file is indexed
36        return store.get_file_hash(rel_path).ok().flatten().is_some();
37    }
38
39    // No watcher: full BLAKE3 check (original behavior)
40    let Some(stored_hash) = store.get_file_hash(rel_path).ok().flatten() else {
41        return false;
42    };
43    let abs_path = project_root.join(rel_path);
44    let Ok(current_hash) = hasher::hash_file(&abs_path) else {
45        return false;
46    };
47    stored_hash == current_hash
48}
49
50/// Serve `find symbol` from the cache. Returns `None` if cache has no results
51/// or any source file is stale.
52pub fn cached_find_symbol(
53    store: &IndexStore,
54    name: &str,
55    project_root: &Path,
56    dirty_files: Option<&DirtyFiles>,
57) -> Option<Vec<SymbolMatch>> {
58    let symbols = store.find_symbols_by_name(name).ok()?;
59    if symbols.is_empty() {
60        return None;
61    }
62
63    // Check freshness for every file containing a match.
64    // If any file is stale, bail out entirely — the LSP path will give correct results.
65    for sym in &symbols {
66        if !is_file_fresh(store, &sym.path, project_root, dirty_files) {
67            return None;
68        }
69    }
70
71    // Group symbol indices by file path to read each file only once
72    let mut by_path: HashMap<&str, Vec<usize>> = HashMap::new();
73    for (i, sym) in symbols.iter().enumerate() {
74        by_path.entry(sym.path.as_str()).or_default().push(i);
75    }
76
77    let mut previews = vec![String::new(); symbols.len()];
78    for (rel_path, indices) in &by_path {
79        let abs = project_root.join(rel_path);
80        if let Ok(content) = std::fs::read_to_string(&abs) {
81            let lines: Vec<&str> = content.lines().collect();
82            for &idx in indices {
83                // Lines are 0-indexed in DB, display as 1-indexed
84                let line_no = symbols[idx].range_start_line as usize;
85                previews[idx] = lines.get(line_no).unwrap_or(&"").trim().to_string();
86            }
87        }
88    }
89
90    let mut results: Vec<SymbolMatch> = symbols
91        .into_iter()
92        .zip(previews)
93        .map(|(sym, preview)| SymbolMatch {
94            path: sym.path,
95            // Lines are 0-indexed in DB, display as 1-indexed
96            line: sym.range_start_line + 1,
97            kind: sym.kind,
98            preview,
99            body: None,
100        })
101        .collect();
102
103    results.sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
104    Some(results)
105}
106
107/// Build a hierarchical `SymbolEntry` tree from flat `CachedSymbol` rows.
108///
109/// Uses `parent_name` to reconstruct nesting. Symbols without a parent are
110/// top-level. Respects `max_depth` (1 = top-level only, 2 = one level of
111/// children, etc.).
112fn build_hierarchy(symbols: &[CachedSymbol], max_depth: u8) -> Vec<SymbolEntry> {
113    // Top-level symbols: no parent
114    let top_level: Vec<&CachedSymbol> =
115        symbols.iter().filter(|s| s.parent_name.is_none()).collect();
116
117    top_level
118        .into_iter()
119        .map(|sym| build_entry(sym, symbols, max_depth, 1))
120        .collect()
121}
122
123fn build_entry(
124    sym: &CachedSymbol,
125    all_symbols: &[CachedSymbol],
126    max_depth: u8,
127    current_depth: u8,
128) -> SymbolEntry {
129    let children = if current_depth < max_depth {
130        all_symbols
131            .iter()
132            .filter(|s| s.parent_name.as_deref() == Some(&sym.name))
133            .map(|child| build_entry(child, all_symbols, max_depth, current_depth + 1))
134            .collect()
135    } else {
136        Vec::new()
137    };
138
139    SymbolEntry {
140        name: sym.name.clone(),
141        kind: sym.kind.clone(),
142        line: sym.range_start_line + 1, // 0-indexed → 1-indexed
143        end_line: sym.range_end_line + 1,
144        children,
145    }
146}
147
148/// Serve `list symbols` from the cache. Returns `None` if file is stale or missing.
149pub fn cached_list_symbols(
150    store: &IndexStore,
151    rel_path: &str,
152    depth: u8,
153    project_root: &Path,
154    dirty_files: Option<&DirtyFiles>,
155) -> Option<Vec<SymbolEntry>> {
156    if !is_file_fresh(store, rel_path, project_root, dirty_files) {
157        return None;
158    }
159
160    let symbols = store.find_symbols_by_path(rel_path).ok()?;
161    if symbols.is_empty() {
162        return None;
163    }
164
165    Some(build_hierarchy(&symbols, depth))
166}
167
168/// Serve `read symbol` from the cache. Returns `None` if symbol not found or file stale.
169///
170/// Supports dotted names (e.g., `Config.new`) by searching for the parent first,
171/// then finding the child in the same file's symbols.
172pub fn cached_read_symbol(
173    store: &IndexStore,
174    name: &str,
175    signature_only: bool,
176    max_lines: Option<u32>,
177    project_root: &Path,
178    dirty_files: Option<&DirtyFiles>,
179) -> Option<Value> {
180    let (search_name, child_name) = if let Some(dot_pos) = name.find('.') {
181        (&name[..dot_pos], Some(&name[dot_pos + 1..]))
182    } else {
183        (name, None)
184    };
185
186    let symbols = store.find_symbols_by_name(search_name).ok()?;
187    if symbols.is_empty() {
188        return None;
189    }
190
191    // Find the target symbol (either the parent, or the child for dotted names)
192    let target = if let Some(child) = child_name {
193        // For dotted names: find a child symbol in the same file as the parent
194        let parent = symbols.first()?;
195        let file_symbols = store.find_symbols_by_path(&parent.path).ok()?;
196        file_symbols
197            .into_iter()
198            .find(|s| s.name == child && s.parent_name.as_deref() == Some(search_name))?
199    } else {
200        // For C/C++: the index may return the header declaration before the source
201        // definition (alphabetical path order: include/ before src/). Prefer the
202        // source file when both exist.
203        const HEADER_EXTS: &[&str] = &["h", "hpp", "hxx", "hh"];
204        let preferred = symbols
205            .iter()
206            .find(|s| {
207                let ext = std::path::Path::new(&s.path)
208                    .extension()
209                    .and_then(|e| e.to_str())
210                    .unwrap_or("");
211                !HEADER_EXTS.contains(&ext)
212            })
213            .or_else(|| symbols.first())
214            .cloned();
215        preferred?
216    };
217
218    // Check file freshness
219    if !is_file_fresh(store, &target.path, project_root, dirty_files) {
220        return None;
221    }
222
223    // Read lines from disk
224    let abs_path = project_root.join(&target.path);
225    let content = std::fs::read_to_string(&abs_path).ok()?;
226    let all_lines: Vec<&str> = content.lines().collect();
227
228    let start = target.range_start_line as usize;
229    let end = (target.range_end_line as usize + 1).min(all_lines.len());
230
231    if start >= all_lines.len() {
232        return None;
233    }
234
235    let selected = &all_lines[start..end];
236
237    let lines: &[&str] = if signature_only {
238        let sig_end = selected
239            .iter()
240            .position(|l| l.contains('{'))
241            .map_or(1, |i| i + 1);
242        &selected[..sig_end.min(selected.len())]
243    } else {
244        selected
245    };
246
247    let max = max_lines.unwrap_or(DEFAULT_MAX_LINES) as usize;
248    let truncated = lines.len() > max;
249    let lines = if truncated { &lines[..max] } else { lines };
250
251    let numbered = format_numbered_lines(lines, start + 1);
252
253    let display_from = start + 1;
254    let display_to = start + lines.len();
255
256    Some(json!({
257        "path": target.path,
258        "symbol": target.name,
259        "kind": target.kind,
260        "content": numbered,
261        "from": display_from,
262        "to": display_to,
263        "truncated": truncated,
264    }))
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn make_store_with_symbols() -> (IndexStore, tempfile::TempDir) {
272        let dir = tempfile::tempdir().unwrap();
273        let store = IndexStore::open_in_memory().unwrap();
274
275        // Create a test file
276        let src_dir = dir.path().join("src");
277        std::fs::create_dir_all(&src_dir).unwrap();
278        std::fs::write(
279            src_dir.join("lib.rs"),
280            "// line 1\nstruct Config {\n    name: String,\n    value: u32,\n}\n\nimpl Config {\n    fn new() -> Self {\n        Config { name: String::new(), value: 0 }\n    }\n}\n",
281        ).unwrap();
282
283        // Hash the file
284        let hash = hasher::hash_file(&src_dir.join("lib.rs")).unwrap();
285        store.upsert_file("src/lib.rs", &hash).unwrap();
286
287        // Insert symbols (0-indexed lines)
288        let symbols = vec![
289            CachedSymbol {
290                name: "Config".into(),
291                kind: "struct".into(),
292                path: "src/lib.rs".into(),
293                range_start_line: 1,
294                range_start_col: 0,
295                range_end_line: 4,
296                range_end_col: 1,
297                parent_name: None,
298            },
299            CachedSymbol {
300                name: "name".into(),
301                kind: "field".into(),
302                path: "src/lib.rs".into(),
303                range_start_line: 2,
304                range_start_col: 4,
305                range_end_line: 2,
306                range_end_col: 20,
307                parent_name: Some("Config".into()),
308            },
309            CachedSymbol {
310                name: "value".into(),
311                kind: "field".into(),
312                path: "src/lib.rs".into(),
313                range_start_line: 3,
314                range_start_col: 4,
315                range_end_line: 3,
316                range_end_col: 15,
317                parent_name: Some("Config".into()),
318            },
319            CachedSymbol {
320                name: "new".into(),
321                kind: "function".into(),
322                path: "src/lib.rs".into(),
323                range_start_line: 7,
324                range_start_col: 4,
325                range_end_line: 9,
326                range_end_col: 5,
327                parent_name: Some("Config".into()),
328            },
329        ];
330        store.insert_symbols("src/lib.rs", &symbols).unwrap();
331
332        (store, dir)
333    }
334
335    // --- is_file_fresh tests (no watcher — BLAKE3 fallback) ---
336
337    #[test]
338    fn file_freshness_matches() {
339        let (store, dir) = make_store_with_symbols();
340        assert!(is_file_fresh(&store, "src/lib.rs", dir.path(), None));
341    }
342
343    #[test]
344    fn file_freshness_stale_after_modify() {
345        let (store, dir) = make_store_with_symbols();
346        std::fs::write(dir.path().join("src/lib.rs"), "modified content").unwrap();
347        assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), None));
348    }
349
350    #[test]
351    fn file_freshness_missing_file() {
352        let store = IndexStore::open_in_memory().unwrap();
353        let dir = tempfile::tempdir().unwrap();
354        assert!(!is_file_fresh(&store, "nonexistent.rs", dir.path(), None));
355    }
356
357    // --- is_file_fresh tests (with watcher) ---
358
359    #[test]
360    fn fresh_with_watcher_clean_file() {
361        let (store, dir) = make_store_with_symbols();
362        let df = DirtyFiles::new();
363        // File is indexed and not dirty → fresh
364        assert!(is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
365    }
366
367    #[test]
368    fn stale_with_watcher_dirty_file() {
369        let (store, dir) = make_store_with_symbols();
370        let df = DirtyFiles::new();
371        df.mark_dirty("src/lib.rs".to_string());
372        // File is dirty → stale (no BLAKE3 needed)
373        assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
374    }
375
376    #[test]
377    fn stale_with_watcher_poisoned() {
378        let (store, dir) = make_store_with_symbols();
379        let df = DirtyFiles::new();
380        df.poison();
381        // Everything is dirty when poisoned
382        assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
383    }
384
385    #[test]
386    fn stale_with_watcher_not_indexed() {
387        let store = IndexStore::open_in_memory().unwrap();
388        let dir = tempfile::tempdir().unwrap();
389        let df = DirtyFiles::new();
390        // File not in index at all → stale
391        assert!(!is_file_fresh(&store, "unknown.rs", dir.path(), Some(&df)));
392    }
393
394    // --- cached_find_symbol ---
395
396    #[test]
397    fn find_symbol_from_cache() {
398        let (store, dir) = make_store_with_symbols();
399        let results = cached_find_symbol(&store, "Config", dir.path(), None).unwrap();
400        assert_eq!(results.len(), 1);
401        assert_eq!(results[0].kind, "struct");
402        assert_eq!(results[0].line, 2);
403        assert_eq!(results[0].path, "src/lib.rs");
404        assert!(results[0].preview.contains("struct Config"));
405    }
406
407    #[test]
408    fn find_symbol_stale_file() {
409        let (store, dir) = make_store_with_symbols();
410        std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
411        assert!(cached_find_symbol(&store, "Config", dir.path(), None).is_none());
412    }
413
414    #[test]
415    fn find_symbol_dirty_via_watcher() {
416        let (store, dir) = make_store_with_symbols();
417        let df = DirtyFiles::new();
418        df.mark_dirty("src/lib.rs".to_string());
419        assert!(cached_find_symbol(&store, "Config", dir.path(), Some(&df)).is_none());
420    }
421
422    #[test]
423    fn find_symbol_clean_via_watcher() {
424        let (store, dir) = make_store_with_symbols();
425        let df = DirtyFiles::new();
426        let results = cached_find_symbol(&store, "Config", dir.path(), Some(&df)).unwrap();
427        assert_eq!(results.len(), 1);
428    }
429
430    #[test]
431    fn find_symbol_not_in_cache() {
432        let (store, dir) = make_store_with_symbols();
433        assert!(cached_find_symbol(&store, "NonExistent", dir.path(), None).is_none());
434    }
435
436    // --- cached_list_symbols ---
437
438    #[test]
439    fn list_symbols_from_cache() {
440        let (store, dir) = make_store_with_symbols();
441        let symbols = cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).unwrap();
442        assert_eq!(symbols.len(), 1);
443        assert_eq!(symbols[0].name, "Config");
444        assert_eq!(symbols[0].children.len(), 3);
445    }
446
447    #[test]
448    fn list_symbols_depth_1() {
449        let (store, dir) = make_store_with_symbols();
450        let symbols = cached_list_symbols(&store, "src/lib.rs", 1, dir.path(), None).unwrap();
451        assert_eq!(symbols.len(), 1);
452        assert!(symbols[0].children.is_empty());
453    }
454
455    #[test]
456    fn list_symbols_stale_file() {
457        let (store, dir) = make_store_with_symbols();
458        std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
459        assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).is_none());
460    }
461
462    #[test]
463    fn list_symbols_dirty_via_watcher() {
464        let (store, dir) = make_store_with_symbols();
465        let df = DirtyFiles::new();
466        df.mark_dirty("src/lib.rs".to_string());
467        assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), Some(&df)).is_none());
468    }
469
470    // --- cached_read_symbol ---
471
472    #[test]
473    fn read_symbol_from_cache() {
474        let (store, dir) = make_store_with_symbols();
475        let result = cached_read_symbol(&store, "Config", false, None, dir.path(), None).unwrap();
476        assert_eq!(result["path"], "src/lib.rs");
477        assert_eq!(result["symbol"], "Config");
478        assert_eq!(result["kind"], "struct");
479        assert_eq!(result["from"], 2);
480        assert_eq!(result["truncated"], false);
481        assert!(result["content"]
482            .as_str()
483            .unwrap()
484            .contains("struct Config"));
485    }
486
487    #[test]
488    fn read_symbol_signature_only() {
489        let (store, dir) = make_store_with_symbols();
490        let result = cached_read_symbol(&store, "Config", true, None, dir.path(), None).unwrap();
491        let content = result["content"].as_str().unwrap();
492        assert!(content.contains("struct Config"));
493        assert!(!content.contains("value"));
494    }
495
496    #[test]
497    fn read_symbol_dotted_name() {
498        let (store, dir) = make_store_with_symbols();
499        let result =
500            cached_read_symbol(&store, "Config.new", false, None, dir.path(), None).unwrap();
501        assert_eq!(result["symbol"], "new");
502        assert_eq!(result["kind"], "function");
503        assert!(result["content"].as_str().unwrap().contains("fn new"));
504    }
505
506    #[test]
507    fn read_symbol_stale_file() {
508        let (store, dir) = make_store_with_symbols();
509        std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
510        assert!(cached_read_symbol(&store, "Config", false, None, dir.path(), None).is_none());
511    }
512
513    #[test]
514    fn read_symbol_dirty_via_watcher() {
515        let (store, dir) = make_store_with_symbols();
516        let df = DirtyFiles::new();
517        df.mark_dirty("src/lib.rs".to_string());
518        assert!(cached_read_symbol(&store, "Config", false, None, dir.path(), Some(&df)).is_none());
519    }
520
521    #[test]
522    fn read_symbol_not_found() {
523        let (store, dir) = make_store_with_symbols();
524        assert!(cached_read_symbol(&store, "NonExistent", false, None, dir.path(), None).is_none());
525    }
526
527    #[test]
528    fn read_symbol_max_lines() {
529        let (store, dir) = make_store_with_symbols();
530        let result =
531            cached_read_symbol(&store, "Config", false, Some(2), dir.path(), None).unwrap();
532        assert_eq!(result["truncated"], true);
533        assert_eq!(result["to"], 3);
534    }
535
536    #[test]
537    fn hierarchy_preserves_line_numbers() {
538        let (store, dir) = make_store_with_symbols();
539        let symbols = cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).unwrap();
540        assert_eq!(symbols[0].line, 2);
541        assert_eq!(symbols[0].end_line, 5);
542    }
543
544    #[test]
545    fn empty_index_returns_none() {
546        let store = IndexStore::open_in_memory().unwrap();
547        let dir = tempfile::tempdir().unwrap();
548
549        assert!(cached_find_symbol(&store, "Foo", dir.path(), None).is_none());
550        assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).is_none());
551        assert!(cached_read_symbol(&store, "Foo", false, None, dir.path(), None).is_none());
552    }
553}