Skip to main content

oo_ide/
file_index.rs

1//! Background file indexer and fuzzy search engine for the file selector.
2//!
3//! Key design points:
4//!
5//! * [`FileIndex`] is built once at startup by [`spawn_indexer`] in a
6//!   `tokio::task::spawn_blocking` thread.
7//! * The result is stored in a [`SharedFileIndex`] which is an
8//!   `Arc<ArcSwap<Option<FileIndex>>>`.  Reads are **lock-free** (`ArcSwap::load`);
9//!   writes are atomic pointer swaps.
10//! * [`spawn_watcher`] keeps the index live: it watches the project root with
11//!   `notify-debouncer-mini`, and rebuilds the full index (in a background
12//!   blocking task) whenever files change.  A 200 ms burst-coalescing sleep
13//!   prevents rebuild storms during e.g. `git checkout`.
14//! * [`NucleoSearch`] wraps `nucleo_matcher` for fast fuzzy matching.
15//!   [`NucleoSearch::search_top`] uses a bounded min-heap (size = `max`) so
16//!   only the top-K entries are ever kept in memory, avoiding a full sort of
17//!   potentially millions of candidates.
18//! * [`FileIndex`] carries a **trigram prefilter**: for queries of ≥ 3 bytes,
19//!   only files whose path contains the first trigram of the (lowercased) query
20//!   are passed to the matcher, reducing matcher calls by 10–50×.
21
22use std::cmp::Reverse;
23use std::collections::BinaryHeap;
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27
28use arc_swap::ArcSwap;
29use ignore::WalkBuilder;
30use ignore::gitignore::GitignoreBuilder;
31
32use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
33use nucleo_matcher::{Config, Matcher, Utf32String};
34use notify::Watcher;
35
36
37// ── Data structures ───────────────────────────────────────────────────────────
38
39pub struct FileEntry {
40    /// Relative path from the project root.
41    pub path: PathBuf,
42    /// Pre-computed UTF-32 representation for nucleo — avoids per-query allocs.
43    utf32: Utf32String,
44}
45
46pub struct FileIndex {
47    files: Vec<FileEntry>,
48    /// Maps each 3-byte lowercase trigram to the indices of files whose
49    /// lowercased path contains that trigram.  Used as a fast prefilter.
50    trigrams: HashMap<[u8; 3], Vec<usize>>,
51}
52
53impl std::fmt::Debug for FileIndex {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(f, "FileIndex({} files)", self.files.len())
56    }
57}
58
59impl FileIndex {
60    /// Walk `root` using `ignore` (respects `.gitignore`) and build the index.
61    ///
62    /// `max_depth` can be provided for shallow indexing (None = full).
63    pub fn build_with_max_depth(root: &Path, max_depth: Option<usize>) -> Self {
64        let mut files = Vec::with_capacity(4096);
65        let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
66
67        let mut builder = WalkBuilder::new(root);
68        builder
69            .hidden(false) // show dotfiles (.gitignore, .env, …)
70            .git_ignore(true) // respect .gitignore — handles target/, node_modules/, …
71            .git_exclude(true)
72            .parents(true);
73
74        if let Some(d) = max_depth {
75            builder.max_depth(Some(d));
76        }
77
78        for result in builder.build() {
79            let Ok(entry) = result else { continue };
80            let Some(ft) = entry.file_type() else {
81                continue;
82            };
83            if !ft.is_file() {
84                continue;
85            }
86
87            let path = entry.path();
88            let rel = path.strip_prefix(root).unwrap_or(path);
89
90            // Belt-and-suspenders: skip common build dirs even when they're not
91            // in .gitignore (e.g. freshly-cloned repos without Cargo.lock).
92            if rel.components().any(|c| {
93                c.as_os_str()
94                    .to_str()
95                    .map(|s| matches!(s, "target" | "node_modules" | "__pycache__"))
96                    .unwrap_or(false)
97            }) {
98                continue;
99            }
100
101            let s = rel.to_string_lossy().replace('\\', "/");
102            let idx = files.len();
103            index_trigrams(s.as_bytes(), idx, &mut trigrams);
104            files.push(FileEntry {
105                path: rel.to_path_buf(),
106                utf32: Utf32String::from(s.as_str()),
107            });
108        }
109
110        FileIndex { files, trigrams }
111    }
112
113    /// Convenience wrapper for the former behaviour: full walk (no depth limit).
114    pub fn build(root: &Path) -> Self {
115        Self::build_with_max_depth(root, None)
116    }
117
118    /// Build from an already-known list of relative paths — used in tests and
119    /// benchmarks where no real filesystem walk is needed.
120    #[allow(dead_code)]
121    pub fn from_paths(paths: Vec<PathBuf>) -> Self {
122        let mut files = Vec::with_capacity(paths.len());
123        let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
124
125        for path in paths {
126            let s = path.to_string_lossy().replace('\\', "/");
127            let idx = files.len();
128            index_trigrams(s.as_bytes(), idx, &mut trigrams);
129            files.push(FileEntry {
130                utf32: Utf32String::from(s.as_str()),
131                path,
132            });
133        }
134
135        FileIndex { files, trigrams }
136    }
137
138    #[allow(dead_code)]
139    pub fn files(&self) -> &[FileEntry] {
140        &self.files
141    }
142
143    /// Return the indices (into `self.files`) of files whose lowercased path
144    /// contains the first trigram (first 3 ASCII bytes, lowercased) of `query`.
145    ///
146    /// Returns `None` when the query is shorter than 3 bytes — callers should
147    /// fall back to scanning all files in that case.
148    fn trigram_candidate_indices(&self, query: &str) -> Option<Vec<usize>> {
149        // Strip spaces before computing trigrams: spaces can't appear in
150        // indexed paths, so a trigram like ['i', ' ', 'v'] (from "multi v")
151        // would never match anything and incorrectly empty the candidate set.
152        let q: Vec<u8> = query.bytes().filter(|&b| b != b' ').collect();
153        if q.len() < 3 {
154            return None;
155        }
156
157        let first = [
158            q[0].to_ascii_lowercase(),
159            q[1].to_ascii_lowercase(),
160            q[2].to_ascii_lowercase(),
161        ];
162
163        let a = self.trigrams.get(&first)?;
164
165        // If query is short we just use the first trigram.
166        if q.len() < 6 {
167            return Some(a.clone());
168        }
169
170        let last = [
171            q[q.len() - 3].to_ascii_lowercase(),
172            q[q.len() - 2].to_ascii_lowercase(),
173            q[q.len() - 1].to_ascii_lowercase(),
174        ];
175
176        let b = match self.trigrams.get(&last) {
177            Some(v) => v,
178            None => return Some(vec![]),
179        };
180
181        // Intersect the two candidate lists
182        let mut out = Vec::with_capacity(a.len().min(b.len()));
183        let set: std::collections::HashSet<_> = b.iter().copied().collect();
184
185        for &idx in a {
186            if set.contains(&idx) {
187                out.push(idx);
188            }
189        }
190
191        Some(out)
192    }
193}
194
195/// Insert all trigrams from `bytes` (lowercased) into `map`, pointing to `idx`.
196fn index_trigrams(bytes: &[u8], idx: usize, map: &mut HashMap<[u8; 3], Vec<usize>>) {
197    use std::collections::HashSet;
198    let mut seen = HashSet::new();
199    for tri in bytes.windows(3) {
200        let key = [
201            tri[0].to_ascii_lowercase(),
202            tri[1].to_ascii_lowercase(),
203            tri[2].to_ascii_lowercase(),
204        ];
205        if seen.insert(key) {
206            map.entry(key).or_default().push(idx);
207        }
208    }
209}
210
211// ── Shared index type ─────────────────────────────────────────────────────────
212
213/// Lock-free shared index.  `None` while the initial walk is still in progress.
214///
215/// Use `index.load()` for reads (returns a `Guard` — no lock taken).
216/// Use `index.store(Arc::new(Some(new_idx)))` for writes (atomic swap).
217pub type SharedFileIndex = Arc<ArcSwap<Option<FileIndex>>>;
218
219// ── Initial indexer ───────────────────────────────────────────────────────────
220
221/// Spawn a background blocking task that builds the index for `root`, then
222/// atomically stores it.  Returns immediately.
223pub fn spawn_indexer(root: PathBuf) -> SharedFileIndex {
224    let shared: SharedFileIndex = Arc::new(ArcSwap::from_pointee(None));
225    let out = shared.clone();
226    tokio::task::spawn_blocking(move || {
227        let idx = FileIndex::build(&root);
228        log::info!("file index built ({} files)", idx.files.len());
229        out.store(Arc::new(Some(idx)));
230    });
231    shared
232}
233
234fn should_trigger_notify_event(kind: &notify::EventKind) -> bool {
235    match kind {
236        notify::EventKind::Create(_) => true,
237        notify::EventKind::Remove(_) => true,
238        notify::EventKind::Modify(mod_kind) => {
239            // Only trigger on name changes (renames) — ignore data/metadata writes.
240            matches!(mod_kind, notify::event::ModifyKind::Name(_))
241        }
242        _ => false,
243    }
244}
245
246// ── Filesystem watcher ────────────────────────────────────────────────────────
247
248/// Determine whether a single path from a notify::Event should be treated as
249/// relevant (i.e., not ignored) according to the repository's .gitignore
250/// semantics. This uses `ignore::WalkBuilder` configured the same way as the
251/// indexer to ensure consistent behavior with FileIndex::build.
252#[cfg(test)]
253fn is_path_relevant(root: &Path, path: &Path) -> bool {
254    // Only consider paths under the repo root; treat external paths as
255    // relevant to be conservative.
256    if let Ok(_rel) = path.strip_prefix(root) {
257        // Build a Gitignore matcher by discovering all .gitignore files in
258        // the repository. This is more expensive than a single WalkBuilder
259        // check but ensures nested .gitignore files are respected exactly
260        // the same way the indexer would.
261        let mut gbuilder = GitignoreBuilder::new(root);
262
263        let mut walker = WalkBuilder::new(root);
264        walker.hidden(false).git_ignore(false).git_exclude(false);
265
266        for result in walker.build() {
267            if let Ok(entry) = result
268                && let Some(name_os) = entry.path().file_name()
269                    && let Some(name) = name_os.to_str()
270                        && name == ".gitignore" {
271                            let _ = gbuilder.add(entry.path());
272                        }
273        }
274
275        // Also include .git/info/exclude when present
276        let git_info_exclude = root.join(".git").join("info").join("exclude");
277        if git_info_exclude.is_file() {
278            let _ = gbuilder.add(git_info_exclude);
279        }
280
281        let gi = match gbuilder.build() {
282            Ok(g) => g,
283            Err(_) => ignore::gitignore::Gitignore::empty(),
284        };
285
286        let is_dir = match path.metadata() {
287            Ok(md) => md.is_dir(),
288            Err(_) => path.extension().is_none(),
289        };
290
291        !gi.matched(path, is_dir).is_ignore()
292    } else {
293        true
294    }
295}
296
297/// Returns true if any path in the event is relevant (not ignored).
298#[cfg(test)]
299fn is_event_relevant(root: &Path, event: &notify::Event) -> bool {
300    event.paths.iter().any(|p| is_path_relevant(root, p))
301}
302
303/// Watch `root` for file-system changes and rebuild the index automatically.
304///
305/// Uses `notify-debouncer-mini` (500 ms window) to collapse burst events from
306/// the OS layer.  The async loop adds a further 200 ms coalescing sleep so that
307/// rapid cascades (e.g. `git checkout`) are collapsed into a single rebuild.
308/// Each rebuild is run in a `spawn_blocking` task; a new trigger aborts any
309/// in-progress rebuild so only the latest one runs.
310pub fn spawn_watcher(root: PathBuf, index: SharedFileIndex) {
311    let (trigger_tx, mut trigger_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
312
313    // Dedicated OS thread owns the raw notify watcher. `trigger_tx` is
314    // cloned into the callback so the async task can coalesce triggers. Keep
315    // the thread alive by blocking on a local receiver instead of repeatedly
316    // parking the thread.
317    {
318        let root = root.clone();
319        let trigger_tx_clone = trigger_tx.clone();
320
321        // Build initial Gitignore matcher once and cache it to avoid a full
322        // WalkBuilder scan on every notify event. Rebuild the matcher only when
323        // a .gitignore (or .git/info/exclude) file changes.
324        fn build_gitignore_for_root(root: &Path) -> ignore::gitignore::Gitignore {
325            let mut gbuilder = GitignoreBuilder::new(root);
326            let mut walker = WalkBuilder::new(root);
327            walker.hidden(false).git_ignore(false).git_exclude(false);
328            for entry in walker.build().filter_map(|r| r.ok()) {
329                if entry.path().file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
330                    let _ = gbuilder.add(entry.path());
331                }
332            }
333            // Also include .git/info/exclude when present
334            let git_info_exclude = root.join(".git").join("info").join("exclude");
335            if git_info_exclude.is_file() {
336                let _ = gbuilder.add(git_info_exclude);
337            }
338            match gbuilder.build() {
339                Ok(g) => g,
340                Err(_) => ignore::gitignore::Gitignore::empty(),
341            }
342        }
343
344        let cached_gi = std::sync::Arc::new(std::sync::Mutex::new(build_gitignore_for_root(&root)));
345
346        std::thread::spawn(move || {
347            // Create a raw notify watcher with a callback that forwards only
348            // structural events for non-ignored paths. Sending into the async
349            // channel is cheap; the async task implements the debounce/batching
350            // semantics.
351            let watcher_root = root.clone();
352            let cached_gi = cached_gi.clone();
353            let mut watcher = match notify::RecommendedWatcher::new(
354                move |res: Result<notify::Event, notify::Error>| {
355                    match res {
356                        Ok(event) => {
357                            // If a .gitignore or .git/info/exclude changed, rebuild cached matcher.
358                            let mut rebuild = false;
359                            for p in &event.paths {
360                                if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
361                                    rebuild = true;
362                                    break;
363                                }
364                                if p.to_string_lossy().ends_with(".git/info/exclude") {
365                                    rebuild = true;
366                                    break;
367                                }
368                            }
369                            if rebuild {
370                                let new_gi = build_gitignore_for_root(&watcher_root);
371                                if let Ok(mut guard) = cached_gi.lock() {
372                                    *guard = new_gi;
373                                }
374                            }
375
376                            // Use cached matcher to decide relevance without a full walk.
377                            let mut any_relevant = false;
378                            for p in &event.paths {
379                                let is_dir = match p.metadata() {
380                                    Ok(md) => md.is_dir(),
381                                    Err(_) => p.extension().is_none(),
382                                };
383                                let guard = cached_gi.lock().unwrap();
384                                if !guard.matched(p, is_dir).is_ignore() {
385                                    any_relevant = true;
386                                    break;
387                                }
388                            }
389                            if !any_relevant {
390                                return;
391                            }
392
393                            if should_trigger_notify_event(&event.kind) {
394                                // best-effort send; ignore errors (receiver closed)
395                                let _ = trigger_tx_clone.send(());
396                            }
397                        }
398                        Err(e) => log::warn!("file watcher error: {e}"),
399                    }
400                },
401                notify::Config::default(),
402            ) {
403                Ok(w) => w,
404                Err(e) => {
405                    log::warn!("file watcher: failed to create watcher: {e}");
406                    return;
407                }
408            };
409
410            if let Err(e) = watcher.watch(&root, notify::RecursiveMode::Recursive) {
411                log::warn!("file watcher: failed to watch {root:?}: {e}");
412                return;
413            }
414
415            // Block the thread indefinitely. Keeping the watcher value in
416            // scope keeps the underlying watcher active.
417            let (_tx_keepalive, rx_keepalive) = std::sync::mpsc::channel::<()>();
418            let _ = rx_keepalive.recv();
419        });
420    }
421
422    // Async task coalesces triggers and schedules index rebuilds. This
423    // implements the "Option B" debounce strategy: drain any already queued
424    // triggers, wait briefly for the filesystem to settle, drain again, then
425    // perform a single rebuild for the burst.
426    tokio::spawn(async move {
427        let mut rebuild: Option<tokio::task::JoinHandle<()>> = None;
428        while trigger_rx.recv().await.is_some() {
429            // Drain any triggers that were queued before we woke up.
430            while trigger_rx.try_recv().is_ok() {}
431
432            // Wait briefly so related events (rename/create/remove etc.) can
433            // arrive — this collapses bursts into a single rebuild.
434            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
435
436            // Drain anything that arrived while we were sleeping.
437            while trigger_rx.try_recv().is_ok() {}
438
439            // Abort any in-progress rebuild and request a fresh one for the
440            // current filesystem snapshot.
441            if let Some(h) = rebuild.take() {
442                h.abort();
443            }
444            let idx = index.clone();
445            let r = root.clone();
446            rebuild = Some(tokio::task::spawn_blocking(move || {
447                idx.store(Arc::new(Some(FileIndex::build(&r))));
448            }));
449        }
450    });
451}
452
453// ── Fuzzy search ─────────────────────────────────────────────────────────────
454
455/// Stateful fuzzy search engine backed by `nucleo_matcher`.
456///
457/// Create one instance per search task; the `Matcher`'s internal scratch
458/// buffer is reused across calls to `search_top` within the same task.
459pub struct NucleoSearch {
460    matcher: Matcher,
461}
462
463impl std::fmt::Debug for NucleoSearch {
464    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465        f.write_str("NucleoSearch")
466    }
467}
468
469impl Default for NucleoSearch {
470    fn default() -> Self {
471        Self::new()
472    }
473}
474
475impl NucleoSearch {
476    pub fn new() -> Self {
477        Self {
478            matcher: Matcher::new(Config::DEFAULT),
479        }
480    }
481
482    /// Fuzzy-search `index` for `query`, returning at most `max` entries
483    /// sorted best-first.
484    ///
485    /// Uses a trigram prefilter to reduce the candidate set, then maintains a
486    /// bounded min-heap of size `max` so the full candidate list is never
487    /// sorted — only the final top-K are extracted and sorted once.
488    pub fn search_top<'a>(
489        &mut self,
490        index: &'a FileIndex,
491        query: &str,
492        max: usize,
493    ) -> Vec<&'a FileEntry> {
494        if query.is_empty() {
495            return index.files.iter().take(max).collect();
496        }
497
498        let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
499
500        let mut heap: BinaryHeap<(Reverse<u32>, usize)> = BinaryHeap::with_capacity(max);
501
502        // Helper closure to score a candidate file index.
503        let mut process_candidate = |fi: usize| {
504            let entry = &index.files[fi];
505
506            let Some(score) = pattern.score(entry.utf32.slice(..), &mut self.matcher) else {
507                return;
508            };
509
510            if heap.len() < max {
511                heap.push((Reverse(score), fi));
512            } else if let Some(&(Reverse(min_score), _)) = heap.peek()
513                && score > min_score {
514                    heap.pop();
515                    heap.push((Reverse(score), fi));
516                }
517        };
518
519        // Use trigram prefilter when possible
520        if let Some(indices) = index.trigram_candidate_indices(query) {
521            for fi in indices {
522                process_candidate(fi);
523            }
524        } else {
525            for fi in 0..index.files.len() {
526                process_candidate(fi);
527            }
528        }
529
530        // Extract and sort results
531        let mut results: Vec<(u32, usize)> = heap
532            .into_iter()
533            .map(|(Reverse(score), idx)| (score, idx))
534            .collect();
535
536        results.sort_unstable_by_key(|b| std::cmp::Reverse(b.0));
537
538        results
539            .into_iter()
540            .map(|(_, idx)| &index.files[idx])
541            .collect()
542    }
543
544    /// Convenience wrapper returning all results.
545    #[allow(dead_code)]
546    pub fn search<'a>(&mut self, index: &'a FileIndex, query: &str) -> Vec<&'a FileEntry> {
547        self.search_top(index, query, usize::MAX)
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554    use notify::event::{CreateKind, DataChange, ModifyKind, RenameMode};
555    use notify::Event;
556    use std::path::PathBuf;
557    use tempfile::tempdir;
558    use std::fs::{create_dir_all, write};
559
560    #[test]
561    fn should_trigger_on_create() {
562        let ev = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![PathBuf::from("a")], attrs: Default::default() };
563        assert!(should_trigger_notify_event(&ev.kind));
564    }
565
566    #[test]
567    fn should_ignore_modify_data() {
568        let ev = Event { kind: notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)), paths: vec![PathBuf::from("a")], attrs: Default::default() };
569        assert!(!should_trigger_notify_event(&ev.kind));
570    }
571
572    #[test]
573    fn should_trigger_rename() {
574        let ev = Event { kind: notify::EventKind::Modify(ModifyKind::Name(RenameMode::Both)), paths: vec![PathBuf::from("a")], attrs: Default::default() };
575        assert!(should_trigger_notify_event(&ev.kind));
576    }
577
578    #[test]
579    fn shallow_build_limits_depth() {
580        let td = tempdir().unwrap();
581        let root = td.path();
582        create_dir_all(root.join("a/b")).unwrap();
583        write(root.join("file1.txt"), b"").unwrap();
584        write(root.join("a").join("file2.txt"), b"").unwrap();
585        write(root.join("a").join("b").join("file3.txt"), b"").unwrap();
586
587        let idx_full = FileIndex::build_with_max_depth(root, None);
588        let paths_full: Vec<String> = idx_full.files.iter().map(|e| e.path.to_string_lossy().replace('\\', "/").to_string()).collect();
589        assert!(paths_full.iter().any(|p| p == "file1.txt"));
590        assert!(paths_full.iter().any(|p| p == "a/file2.txt"));
591        assert!(paths_full.iter().any(|p| p == "a/b/file3.txt"));
592
593        let idx_shallow = FileIndex::build_with_max_depth(root, Some(1));
594        let paths_shallow: Vec<String> = idx_shallow.files.iter().map(|e| e.path.to_string_lossy().replace('\\', "/").to_string()).collect();
595        assert!(paths_shallow.iter().any(|p| p == "file1.txt"));
596        assert!(!paths_shallow.iter().any(|p| p == "a/file2.txt"));
597        assert!(!paths_shallow.iter().any(|p| p == "a/b/file3.txt"));
598    }
599
600    #[test]
601    fn walkbuilder_respects_gitignore() {
602        let td = tempdir().unwrap();
603        let root = td.path();
604        // create .gitignore listing ignored.txt
605        write(root.join(".gitignore"), b"ignored.txt\n").unwrap();
606        write(root.join("ignored.txt"), b"").unwrap();
607        write(root.join("not_ignored.txt"), b"").unwrap();
608
609        // event for ignored file should be filtered out
610        let ev_ignored = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![root.join("ignored.txt")], attrs: Default::default() };
611        assert!(!is_event_relevant(root, &ev_ignored));
612
613        // event for not ignored file should be relevant
614        let ev_not = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![root.join("not_ignored.txt")], attrs: Default::default() };
615        assert!(is_event_relevant(root, &ev_not));
616    }
617}