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        symbols.into_iter().next()?
201    };
202
203    // Check file freshness
204    if !is_file_fresh(store, &target.path, project_root, dirty_files) {
205        return None;
206    }
207
208    // Read lines from disk
209    let abs_path = project_root.join(&target.path);
210    let content = std::fs::read_to_string(&abs_path).ok()?;
211    let all_lines: Vec<&str> = content.lines().collect();
212
213    let start = target.range_start_line as usize;
214    let end = (target.range_end_line as usize + 1).min(all_lines.len());
215
216    if start >= all_lines.len() {
217        return None;
218    }
219
220    let selected = &all_lines[start..end];
221
222    let lines: &[&str] = if signature_only {
223        let sig_end = selected
224            .iter()
225            .position(|l| l.contains('{'))
226            .map_or(1, |i| i + 1);
227        &selected[..sig_end.min(selected.len())]
228    } else {
229        selected
230    };
231
232    let max = max_lines.unwrap_or(DEFAULT_MAX_LINES) as usize;
233    let truncated = lines.len() > max;
234    let lines = if truncated { &lines[..max] } else { lines };
235
236    let numbered = format_numbered_lines(lines, start + 1);
237
238    let display_from = start + 1;
239    let display_to = start + lines.len();
240
241    Some(json!({
242        "path": target.path,
243        "symbol": target.name,
244        "kind": target.kind,
245        "content": numbered,
246        "from": display_from,
247        "to": display_to,
248        "truncated": truncated,
249    }))
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn make_store_with_symbols() -> (IndexStore, tempfile::TempDir) {
257        let dir = tempfile::tempdir().unwrap();
258        let store = IndexStore::open_in_memory().unwrap();
259
260        // Create a test file
261        let src_dir = dir.path().join("src");
262        std::fs::create_dir_all(&src_dir).unwrap();
263        std::fs::write(
264            src_dir.join("lib.rs"),
265            "// 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",
266        ).unwrap();
267
268        // Hash the file
269        let hash = hasher::hash_file(&src_dir.join("lib.rs")).unwrap();
270        store.upsert_file("src/lib.rs", &hash).unwrap();
271
272        // Insert symbols (0-indexed lines)
273        let symbols = vec![
274            CachedSymbol {
275                name: "Config".into(),
276                kind: "struct".into(),
277                path: "src/lib.rs".into(),
278                range_start_line: 1,
279                range_start_col: 0,
280                range_end_line: 4,
281                range_end_col: 1,
282                parent_name: None,
283            },
284            CachedSymbol {
285                name: "name".into(),
286                kind: "field".into(),
287                path: "src/lib.rs".into(),
288                range_start_line: 2,
289                range_start_col: 4,
290                range_end_line: 2,
291                range_end_col: 20,
292                parent_name: Some("Config".into()),
293            },
294            CachedSymbol {
295                name: "value".into(),
296                kind: "field".into(),
297                path: "src/lib.rs".into(),
298                range_start_line: 3,
299                range_start_col: 4,
300                range_end_line: 3,
301                range_end_col: 15,
302                parent_name: Some("Config".into()),
303            },
304            CachedSymbol {
305                name: "new".into(),
306                kind: "function".into(),
307                path: "src/lib.rs".into(),
308                range_start_line: 7,
309                range_start_col: 4,
310                range_end_line: 9,
311                range_end_col: 5,
312                parent_name: Some("Config".into()),
313            },
314        ];
315        store.insert_symbols("src/lib.rs", &symbols).unwrap();
316
317        (store, dir)
318    }
319
320    // --- is_file_fresh tests (no watcher — BLAKE3 fallback) ---
321
322    #[test]
323    fn file_freshness_matches() {
324        let (store, dir) = make_store_with_symbols();
325        assert!(is_file_fresh(&store, "src/lib.rs", dir.path(), None));
326    }
327
328    #[test]
329    fn file_freshness_stale_after_modify() {
330        let (store, dir) = make_store_with_symbols();
331        std::fs::write(dir.path().join("src/lib.rs"), "modified content").unwrap();
332        assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), None));
333    }
334
335    #[test]
336    fn file_freshness_missing_file() {
337        let store = IndexStore::open_in_memory().unwrap();
338        let dir = tempfile::tempdir().unwrap();
339        assert!(!is_file_fresh(&store, "nonexistent.rs", dir.path(), None));
340    }
341
342    // --- is_file_fresh tests (with watcher) ---
343
344    #[test]
345    fn fresh_with_watcher_clean_file() {
346        let (store, dir) = make_store_with_symbols();
347        let df = DirtyFiles::new();
348        // File is indexed and not dirty → fresh
349        assert!(is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
350    }
351
352    #[test]
353    fn stale_with_watcher_dirty_file() {
354        let (store, dir) = make_store_with_symbols();
355        let df = DirtyFiles::new();
356        df.mark_dirty("src/lib.rs".to_string());
357        // File is dirty → stale (no BLAKE3 needed)
358        assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
359    }
360
361    #[test]
362    fn stale_with_watcher_poisoned() {
363        let (store, dir) = make_store_with_symbols();
364        let df = DirtyFiles::new();
365        df.poison();
366        // Everything is dirty when poisoned
367        assert!(!is_file_fresh(&store, "src/lib.rs", dir.path(), Some(&df)));
368    }
369
370    #[test]
371    fn stale_with_watcher_not_indexed() {
372        let store = IndexStore::open_in_memory().unwrap();
373        let dir = tempfile::tempdir().unwrap();
374        let df = DirtyFiles::new();
375        // File not in index at all → stale
376        assert!(!is_file_fresh(&store, "unknown.rs", dir.path(), Some(&df)));
377    }
378
379    // --- cached_find_symbol ---
380
381    #[test]
382    fn find_symbol_from_cache() {
383        let (store, dir) = make_store_with_symbols();
384        let results = cached_find_symbol(&store, "Config", dir.path(), None).unwrap();
385        assert_eq!(results.len(), 1);
386        assert_eq!(results[0].kind, "struct");
387        assert_eq!(results[0].line, 2);
388        assert_eq!(results[0].path, "src/lib.rs");
389        assert!(results[0].preview.contains("struct Config"));
390    }
391
392    #[test]
393    fn find_symbol_stale_file() {
394        let (store, dir) = make_store_with_symbols();
395        std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
396        assert!(cached_find_symbol(&store, "Config", dir.path(), None).is_none());
397    }
398
399    #[test]
400    fn find_symbol_dirty_via_watcher() {
401        let (store, dir) = make_store_with_symbols();
402        let df = DirtyFiles::new();
403        df.mark_dirty("src/lib.rs".to_string());
404        assert!(cached_find_symbol(&store, "Config", dir.path(), Some(&df)).is_none());
405    }
406
407    #[test]
408    fn find_symbol_clean_via_watcher() {
409        let (store, dir) = make_store_with_symbols();
410        let df = DirtyFiles::new();
411        let results = cached_find_symbol(&store, "Config", dir.path(), Some(&df)).unwrap();
412        assert_eq!(results.len(), 1);
413    }
414
415    #[test]
416    fn find_symbol_not_in_cache() {
417        let (store, dir) = make_store_with_symbols();
418        assert!(cached_find_symbol(&store, "NonExistent", dir.path(), None).is_none());
419    }
420
421    // --- cached_list_symbols ---
422
423    #[test]
424    fn list_symbols_from_cache() {
425        let (store, dir) = make_store_with_symbols();
426        let symbols = cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).unwrap();
427        assert_eq!(symbols.len(), 1);
428        assert_eq!(symbols[0].name, "Config");
429        assert_eq!(symbols[0].children.len(), 3);
430    }
431
432    #[test]
433    fn list_symbols_depth_1() {
434        let (store, dir) = make_store_with_symbols();
435        let symbols = cached_list_symbols(&store, "src/lib.rs", 1, dir.path(), None).unwrap();
436        assert_eq!(symbols.len(), 1);
437        assert!(symbols[0].children.is_empty());
438    }
439
440    #[test]
441    fn list_symbols_stale_file() {
442        let (store, dir) = make_store_with_symbols();
443        std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
444        assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).is_none());
445    }
446
447    #[test]
448    fn list_symbols_dirty_via_watcher() {
449        let (store, dir) = make_store_with_symbols();
450        let df = DirtyFiles::new();
451        df.mark_dirty("src/lib.rs".to_string());
452        assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), Some(&df)).is_none());
453    }
454
455    // --- cached_read_symbol ---
456
457    #[test]
458    fn read_symbol_from_cache() {
459        let (store, dir) = make_store_with_symbols();
460        let result = cached_read_symbol(&store, "Config", false, None, dir.path(), None).unwrap();
461        assert_eq!(result["path"], "src/lib.rs");
462        assert_eq!(result["symbol"], "Config");
463        assert_eq!(result["kind"], "struct");
464        assert_eq!(result["from"], 2);
465        assert_eq!(result["truncated"], false);
466        assert!(result["content"]
467            .as_str()
468            .unwrap()
469            .contains("struct Config"));
470    }
471
472    #[test]
473    fn read_symbol_signature_only() {
474        let (store, dir) = make_store_with_symbols();
475        let result = cached_read_symbol(&store, "Config", true, None, dir.path(), None).unwrap();
476        let content = result["content"].as_str().unwrap();
477        assert!(content.contains("struct Config"));
478        assert!(!content.contains("value"));
479    }
480
481    #[test]
482    fn read_symbol_dotted_name() {
483        let (store, dir) = make_store_with_symbols();
484        let result =
485            cached_read_symbol(&store, "Config.new", false, None, dir.path(), None).unwrap();
486        assert_eq!(result["symbol"], "new");
487        assert_eq!(result["kind"], "function");
488        assert!(result["content"].as_str().unwrap().contains("fn new"));
489    }
490
491    #[test]
492    fn read_symbol_stale_file() {
493        let (store, dir) = make_store_with_symbols();
494        std::fs::write(dir.path().join("src/lib.rs"), "modified").unwrap();
495        assert!(cached_read_symbol(&store, "Config", false, None, dir.path(), None).is_none());
496    }
497
498    #[test]
499    fn read_symbol_dirty_via_watcher() {
500        let (store, dir) = make_store_with_symbols();
501        let df = DirtyFiles::new();
502        df.mark_dirty("src/lib.rs".to_string());
503        assert!(cached_read_symbol(&store, "Config", false, None, dir.path(), Some(&df)).is_none());
504    }
505
506    #[test]
507    fn read_symbol_not_found() {
508        let (store, dir) = make_store_with_symbols();
509        assert!(cached_read_symbol(&store, "NonExistent", false, None, dir.path(), None).is_none());
510    }
511
512    #[test]
513    fn read_symbol_max_lines() {
514        let (store, dir) = make_store_with_symbols();
515        let result =
516            cached_read_symbol(&store, "Config", false, Some(2), dir.path(), None).unwrap();
517        assert_eq!(result["truncated"], true);
518        assert_eq!(result["to"], 3);
519    }
520
521    #[test]
522    fn hierarchy_preserves_line_numbers() {
523        let (store, dir) = make_store_with_symbols();
524        let symbols = cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).unwrap();
525        assert_eq!(symbols[0].line, 2);
526        assert_eq!(symbols[0].end_line, 5);
527    }
528
529    #[test]
530    fn empty_index_returns_none() {
531        let store = IndexStore::open_in_memory().unwrap();
532        let dir = tempfile::tempdir().unwrap();
533
534        assert!(cached_find_symbol(&store, "Foo", dir.path(), None).is_none());
535        assert!(cached_list_symbols(&store, "src/lib.rs", 2, dir.path(), None).is_none());
536        assert!(cached_read_symbol(&store, "Foo", false, None, dir.path(), None).is_none());
537    }
538}