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::collections::HashSet;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29use arc_swap::ArcSwap;
30use ignore::WalkBuilder;
31use ignore::gitignore::GitignoreBuilder;
32
33use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
34use nucleo_matcher::{Config, Matcher, Utf32String};
35use notify::Watcher;
36
37
38// ── Data structures ───────────────────────────────────────────────────────────
39
40pub struct FileEntry {
41    /// Relative path from the project root.
42    pub path: PathBuf,
43    /// Pre-computed UTF-32 representation for nucleo — avoids per-query allocs.
44    utf32: Utf32String,
45}
46
47/// A single entry in a directory listing produced by [`ProjectFileRegistry`].
48#[derive(Debug, Clone)]
49pub struct DirEntry {
50    /// Relative path from the project root.
51    pub path: PathBuf,
52    pub is_dir: bool,
53}
54
55pub struct FileIndex {
56    files: Vec<FileEntry>,
57    /// Maps each 3-byte lowercase trigram to the indices of files whose
58    /// lowercased path contains that trigram.  Used as a fast prefilter.
59    trigrams: HashMap<[u8; 3], Vec<usize>>,
60}
61
62impl std::fmt::Debug for FileIndex {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "FileIndex({} files)", self.files.len())
65    }
66}
67
68impl FileIndex {
69    /// Walk `root` using `ignore` (respects `.gitignore`) and build the index.
70    ///
71    /// `max_depth` can be provided for shallow indexing (None = full).
72    pub fn build_with_max_depth(root: &Path, max_depth: Option<usize>) -> Self {
73        let mut files = Vec::with_capacity(4096);
74        let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
75
76        let mut builder = WalkBuilder::new(root);
77        builder
78            .hidden(false) // show dotfiles (.gitignore, .env, …)
79            .git_ignore(true) // respect .gitignore — handles target/, node_modules/, …
80            .git_exclude(true)
81            .parents(true);
82
83        if let Some(d) = max_depth {
84            builder.max_depth(Some(d));
85        }
86
87        for result in builder.build() {
88            let Ok(entry) = result else { continue };
89            let Some(ft) = entry.file_type() else {
90                continue;
91            };
92            if !ft.is_file() {
93                continue;
94            }
95
96            let path = entry.path();
97            let rel = path.strip_prefix(root).unwrap_or(path);
98
99            // Belt-and-suspenders: skip common build dirs even when they're not
100            // in .gitignore (e.g. freshly-cloned repos without Cargo.lock).
101            if rel.components().any(|c| {
102                c.as_os_str()
103                    .to_str()
104                    .map(|s| matches!(s, "target" | "node_modules" | "__pycache__"))
105                    .unwrap_or(false)
106            }) {
107                continue;
108            }
109
110            let s = rel.to_string_lossy().replace('\\', "/");
111            let idx = files.len();
112            index_trigrams(s.as_bytes(), idx, &mut trigrams);
113            files.push(FileEntry {
114                path: rel.to_path_buf(),
115                utf32: Utf32String::from(s.as_str()),
116            });
117        }
118
119        FileIndex { files, trigrams }
120    }
121
122    /// Convenience wrapper for the former behaviour: full walk (no depth limit).
123    pub fn build(root: &Path) -> Self {
124        Self::build_with_max_depth(root, None)
125    }
126
127    /// Build from an already-known list of relative paths — used in tests and
128    /// benchmarks where no real filesystem walk is needed.
129    #[allow(dead_code)]
130    pub fn from_paths(paths: Vec<PathBuf>) -> Self {
131        let mut files = Vec::with_capacity(paths.len());
132        let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
133
134        for path in paths {
135            let s = path.to_string_lossy().replace('\\', "/");
136            let idx = files.len();
137            index_trigrams(s.as_bytes(), idx, &mut trigrams);
138            files.push(FileEntry {
139                utf32: Utf32String::from(s.as_str()),
140                path,
141            });
142        }
143
144        FileIndex { files, trigrams }
145    }
146
147    #[allow(dead_code)]
148    pub fn files(&self) -> &[FileEntry] {
149        &self.files
150    }
151
152    /// Return the indices (into `self.files`) of files whose lowercased path
153    /// contains the first trigram (first 3 ASCII bytes, lowercased) of `query`.
154    ///
155    /// Returns `None` when the query is shorter than 3 bytes — callers should
156    /// fall back to scanning all files in that case.
157    fn trigram_candidate_indices(&self, query: &str) -> Option<Vec<usize>> {
158        // Strip spaces before computing trigrams: spaces can't appear in
159        // indexed paths, so a trigram like ['i', ' ', 'v'] (from "multi v")
160        // would never match anything and incorrectly empty the candidate set.
161        let q: Vec<u8> = query.bytes().filter(|&b| b != b' ').collect();
162        if q.len() < 3 {
163            return None;
164        }
165
166        let first = [
167            q[0].to_ascii_lowercase(),
168            q[1].to_ascii_lowercase(),
169            q[2].to_ascii_lowercase(),
170        ];
171
172        let a = self.trigrams.get(&first)?;
173
174        // If query is short we just use the first trigram.
175        if q.len() < 6 {
176            return Some(a.clone());
177        }
178
179        let last = [
180            q[q.len() - 3].to_ascii_lowercase(),
181            q[q.len() - 2].to_ascii_lowercase(),
182            q[q.len() - 1].to_ascii_lowercase(),
183        ];
184
185        let b = match self.trigrams.get(&last) {
186            Some(v) => v,
187            None => return Some(vec![]),
188        };
189
190        // Intersect the two candidate lists
191        let mut out = Vec::with_capacity(a.len().min(b.len()));
192        let set: std::collections::HashSet<_> = b.iter().copied().collect();
193
194        for &idx in a {
195            if set.contains(&idx) {
196                out.push(idx);
197            }
198        }
199
200        Some(out)
201    }
202}
203
204/// Insert all trigrams from `bytes` (lowercased) into `map`, pointing to `idx`.
205fn index_trigrams(bytes: &[u8], idx: usize, map: &mut HashMap<[u8; 3], Vec<usize>>) {
206    use std::collections::HashSet;
207    let mut seen = HashSet::new();
208    for tri in bytes.windows(3) {
209        let key = [
210            tri[0].to_ascii_lowercase(),
211            tri[1].to_ascii_lowercase(),
212            tri[2].to_ascii_lowercase(),
213        ];
214        if seen.insert(key) {
215            map.entry(key).or_default().push(idx);
216        }
217    }
218}
219
220// ── Shared index type ─────────────────────────────────────────────────────────
221
222/// Lock-free shared index.  `None` while the initial walk is still in progress.
223///
224/// Use `index.load()` for reads (returns a `Guard` — no lock taken).
225/// Use `index.store(Arc::new(Some(new_idx)))` for writes (atomic swap).
226pub type SharedFileIndex = Arc<ArcSwap<Option<FileIndex>>>;
227
228// ── ProjectFileRegistry ───────────────────────────────────────────────────────
229
230/// Unified project index built in a single filesystem walk.
231///
232/// Holds everything callers need:
233/// - `files` / trigrams for fuzzy search (same as [`FileIndex`])
234/// - `dir_children` for O(1) file-tree expansion
235/// - `file_set` for O(1) existence checks
236pub struct ProjectFileRegistry {
237    inner: FileIndex,
238    /// Maps each relative directory path → its immediate children, pre-sorted
239    /// (dirs first, then alphabetically).
240    dir_children: HashMap<PathBuf, Vec<DirEntry>>,
241    /// Set of all relative file paths for O(1) existence checks.
242    file_set: HashSet<PathBuf>,
243}
244
245impl std::fmt::Debug for ProjectFileRegistry {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        write!(f, "ProjectFileRegistry({} files)", self.inner.files.len())
248    }
249}
250
251impl Default for ProjectFileRegistry {
252    fn default() -> Self {
253        ProjectFileRegistry {
254            inner: FileIndex { files: vec![], trigrams: HashMap::new() },
255            dir_children: HashMap::new(),
256            file_set: HashSet::new(),
257        }
258    }
259}
260
261impl ProjectFileRegistry {
262    /// Walk `root` and build the full registry in a single pass.
263    pub fn build(root: &Path) -> Self {
264        let mut files = Vec::with_capacity(4096);
265        let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
266        let mut dir_map: HashMap<PathBuf, Vec<DirEntry>> = HashMap::new();
267        let mut file_set: HashSet<PathBuf> = HashSet::new();
268
269        for result in WalkBuilder::new(root)
270            .hidden(false)
271            .git_ignore(true)
272            .git_exclude(true)
273            .parents(true)
274            .build()
275        {
276            let Ok(entry) = result else { continue };
277            let Some(ft) = entry.file_type() else { continue };
278
279            let path = entry.path();
280            let rel = path.strip_prefix(root).unwrap_or(path);
281
282            // Skip the root directory entry itself — we don't want an empty
283            // relative path stored as a child of the project root.
284            if rel.as_os_str().is_empty() {
285                continue;
286            }
287
288            // Belt-and-suspenders: skip build dirs even when not in .gitignore.
289            if rel.components().any(|c| {
290                c.as_os_str()
291                    .to_str()
292                    .map(|s| matches!(s, "target" | "node_modules" | "__pycache__"))
293                    .unwrap_or(false)
294            }) {
295                continue;
296            }
297
298            let is_dir = ft.is_dir();
299
300            // Populate dir_children for this entry's parent. Use an empty
301            // relative path for project-root children so root_children() works
302            // reliably regardless of Path::parent() semantics.
303            let parent = rel.parent().unwrap_or(Path::new(""));
304            dir_map.entry(parent.to_path_buf()).or_default().push(DirEntry { path: rel.to_path_buf(), is_dir });
305
306            if !is_dir {
307                let s = rel.to_string_lossy().replace('\\', "/");
308                let idx = files.len();
309                index_trigrams(s.as_bytes(), idx, &mut trigrams);
310                file_set.insert(rel.to_path_buf());
311                files.push(FileEntry {
312                    path: rel.to_path_buf(),
313                    utf32: Utf32String::from(s.as_str()),
314                });
315            }
316        }
317
318        // Sort each dir's children: dirs first, then alphabetically.
319        for children in dir_map.values_mut() {
320            children.sort_unstable_by(|a, b| {
321                b.is_dir.cmp(&a.is_dir).then_with(|| a.path.cmp(&b.path))
322            });
323        }
324
325        let file_count = files.len();
326        let reg = ProjectFileRegistry {
327            inner: FileIndex { files, trigrams },
328            dir_children: dir_map,
329            file_set,
330        };
331        log::info!("project registry built ({file_count} files)");
332        reg
333    }
334
335    /// All indexed files (for fuzzy search).
336    pub fn files(&self) -> &[FileEntry] {
337        &self.inner.files
338    }
339
340    /// Access the inner [`FileIndex`] for use with [`NucleoSearch`].
341    pub fn file_index(&self) -> &FileIndex {
342        &self.inner
343    }
344
345    /// Immediate children of `rel_dir` (relative path), pre-sorted
346    /// (dirs first, then alphabetically).
347    ///
348    /// Returns an empty slice when the directory is empty or not indexed.
349    pub fn children_of(&self, rel_dir: &Path) -> &[DirEntry] {
350        self.dir_children.get(rel_dir).map(|v| v.as_slice()).unwrap_or(&[])
351    }
352
353    /// Root-level entries (children of the project root itself).
354    pub fn root_children(&self) -> &[DirEntry] {
355        self.children_of(Path::new(""))
356    }
357
358    /// All relative file paths whose prefix matches `rel_dir`.
359    ///
360    /// Allocates a `Vec`; intended for batch use (e.g. project-search seed list).
361    pub fn files_under(&self, rel_dir: &Path) -> Vec<&Path> {
362        self.inner
363            .files
364            .iter()
365            .filter(|e| e.path.starts_with(rel_dir))
366            .map(|e| e.path.as_path())
367            .collect()
368    }
369
370    /// Whether a relative path exists in the index.  O(1).
371    pub fn contains(&self, rel: &Path) -> bool {
372        self.file_set.contains(rel)
373    }
374}
375
376/// Lock-free shared registry. Clone freely — it is an `Arc<ArcSwap<…>>`.
377///
378/// Use `registry.load()` for reads (returns a `Guard` — no lock taken).
379/// Use `registry.store(Arc::new(new_reg))` for writes (atomic swap).
380pub type SharedRegistry = Arc<ArcSwap<Option<ProjectFileRegistry>>>;
381
382/// Spawn the indexer and watcher for `root`. Returns the shared handle
383/// immediately; the registry is populated asynchronously.
384pub fn spawn_registry(root: PathBuf) -> SharedRegistry {
385    let shared: SharedRegistry = Arc::new(ArcSwap::from_pointee(None));
386
387    // Initial build in a background blocking task.
388    {
389        let out = shared.clone();
390        let r = root.clone();
391        tokio::task::spawn_blocking(move || {
392            out.store(Arc::new(Some(ProjectFileRegistry::build(&r))));
393        });
394    }
395
396    // Filesystem watcher — same debounce strategy as spawn_watcher.
397    let (trigger_tx, mut trigger_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
398    {
399        let root_clone = root.clone();
400        let trigger_tx_clone = trigger_tx.clone();
401
402        fn build_gitignore_registry(root: &Path) -> ignore::gitignore::Gitignore {
403            let mut gbuilder = GitignoreBuilder::new(root);
404            let mut walker = WalkBuilder::new(root);
405            walker.hidden(false).git_ignore(false).git_exclude(false);
406            for entry in walker.build().filter_map(|r| r.ok()) {
407                if entry.path().file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
408                    let _ = gbuilder.add(entry.path());
409                }
410            }
411            let git_info_exclude = root.join(".git").join("info").join("exclude");
412            if git_info_exclude.is_file() {
413                let _ = gbuilder.add(git_info_exclude);
414            }
415            match gbuilder.build() {
416                Ok(g) => g,
417                Err(_) => ignore::gitignore::Gitignore::empty(),
418            }
419        }
420
421        let cached_gi = std::sync::Arc::new(std::sync::Mutex::new(
422            build_gitignore_registry(&root_clone),
423        ));
424
425        std::thread::spawn(move || {
426            let watcher_root = root_clone.clone();
427            let cached_gi_clone = cached_gi.clone();
428            let mut watcher = match notify::RecommendedWatcher::new(
429                move |res: Result<notify::Event, notify::Error>| match res {
430                    Ok(event) => {
431                        let mut rebuild_gi = false;
432                        for p in &event.paths {
433                            if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore")
434                                || p.to_string_lossy().ends_with(".git/info/exclude")
435                            {
436                                rebuild_gi = true;
437                                break;
438                            }
439                        }
440                        if rebuild_gi {
441                            let new_gi = build_gitignore_registry(&watcher_root);
442                            if let Ok(mut g) = cached_gi_clone.lock() {
443                                *g = new_gi;
444                            }
445                        }
446                        let mut any_relevant = false;
447                        for p in &event.paths {
448                            let is_dir = p.metadata().map(|md| md.is_dir()).unwrap_or(false);
449                            let g = cached_gi_clone.lock().unwrap();
450                            if !g.matched(p, is_dir).is_ignore() {
451                                any_relevant = true;
452                                break;
453                            }
454                        }
455                        if any_relevant && should_trigger_notify_event(&event.kind) {
456                            let _ = trigger_tx_clone.send(());
457                        }
458                    }
459                    Err(e) => log::warn!("registry watcher error: {e}"),
460                },
461                notify::Config::default(),
462            ) {
463                Ok(w) => w,
464                Err(e) => {
465                    log::warn!("failed to create registry watcher: {e}");
466                    return;
467                }
468            };
469
470            if let Err(e) = watcher.watch(&root_clone, notify::RecursiveMode::Recursive) {
471                log::warn!("registry watcher failed to watch {root_clone:?}: {e}");
472                return;
473            }
474
475            // Keep the thread (and the watcher) alive indefinitely.
476            let (_tx, rx) = std::sync::mpsc::channel::<()>();
477            let _ = rx.recv();
478        });
479    }
480
481    let index = shared.clone();
482    tokio::spawn(async move {
483        let mut rebuild: Option<tokio::task::JoinHandle<()>> = None;
484        while trigger_rx.recv().await.is_some() {
485            while trigger_rx.try_recv().is_ok() {}
486            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
487            while trigger_rx.try_recv().is_ok() {}
488            if let Some(h) = rebuild.take() {
489                h.abort();
490            }
491            let reg = index.clone();
492            let r = root.clone();
493            rebuild = Some(tokio::task::spawn_blocking(move || {
494                reg.store(Arc::new(Some(ProjectFileRegistry::build(&r))));
495            }));
496        }
497    });
498
499    shared
500}
501
502// ── Initial indexer ───────────────────────────────────────────────────────────
503
504/// Spawn a background blocking task that builds the index for `root`, then
505/// atomically stores it.  Returns immediately.
506pub fn spawn_indexer(root: PathBuf) -> SharedFileIndex {
507    let shared: SharedFileIndex = Arc::new(ArcSwap::from_pointee(None));
508    let out = shared.clone();
509    tokio::task::spawn_blocking(move || {
510        let idx = FileIndex::build(&root);
511        log::info!("file index built ({} files)", idx.files.len());
512        out.store(Arc::new(Some(idx)));
513    });
514    shared
515}
516
517fn should_trigger_notify_event(kind: &notify::EventKind) -> bool {
518    match kind {
519        notify::EventKind::Create(_) => true,
520        notify::EventKind::Remove(_) => true,
521        notify::EventKind::Modify(mod_kind) => {
522            // Only trigger on name changes (renames) — ignore data/metadata writes.
523            matches!(mod_kind, notify::event::ModifyKind::Name(_))
524        }
525        _ => false,
526    }
527}
528
529// ── Filesystem watcher ────────────────────────────────────────────────────────
530
531/// Determine whether a single path from a notify::Event should be treated as
532/// relevant (i.e., not ignored) according to the repository's .gitignore
533/// semantics. This uses `ignore::WalkBuilder` configured the same way as the
534/// indexer to ensure consistent behavior with FileIndex::build.
535#[cfg(test)]
536fn is_path_relevant(root: &Path, path: &Path) -> bool {
537    // Only consider paths under the repo root; treat external paths as
538    // relevant to be conservative.
539    if let Ok(_rel) = path.strip_prefix(root) {
540        // Build a Gitignore matcher by discovering all .gitignore files in
541        // the repository. This is more expensive than a single WalkBuilder
542        // check but ensures nested .gitignore files are respected exactly
543        // the same way the indexer would.
544        let mut gbuilder = GitignoreBuilder::new(root);
545
546        let mut walker = WalkBuilder::new(root);
547        walker.hidden(false).git_ignore(false).git_exclude(false);
548
549        for result in walker.build() {
550            if let Ok(entry) = result
551                && let Some(name_os) = entry.path().file_name()
552                    && let Some(name) = name_os.to_str()
553                        && name == ".gitignore" {
554                            let _ = gbuilder.add(entry.path());
555                        }
556        }
557
558        // Also include .git/info/exclude when present
559        let git_info_exclude = root.join(".git").join("info").join("exclude");
560        if git_info_exclude.is_file() {
561            let _ = gbuilder.add(git_info_exclude);
562        }
563
564        let gi = match gbuilder.build() {
565            Ok(g) => g,
566            Err(_) => ignore::gitignore::Gitignore::empty(),
567        };
568
569        let is_dir = match path.metadata() {
570            Ok(md) => md.is_dir(),
571            Err(_) => path.extension().is_none(),
572        };
573
574        !gi.matched(path, is_dir).is_ignore()
575    } else {
576        true
577    }
578}
579
580/// Returns true if any path in the event is relevant (not ignored).
581#[cfg(test)]
582fn is_event_relevant(root: &Path, event: &notify::Event) -> bool {
583    event.paths.iter().any(|p| is_path_relevant(root, p))
584}
585
586/// Watch `root` for file-system changes and rebuild the index automatically.
587///
588/// Uses `notify-debouncer-mini` (500 ms window) to collapse burst events from
589/// the OS layer.  The async loop adds a further 200 ms coalescing sleep so that
590/// rapid cascades (e.g. `git checkout`) are collapsed into a single rebuild.
591/// Each rebuild is run in a `spawn_blocking` task; a new trigger aborts any
592/// in-progress rebuild so only the latest one runs.
593pub fn spawn_watcher(root: PathBuf, index: SharedFileIndex) {
594    let (trigger_tx, mut trigger_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
595
596    // Dedicated OS thread owns the raw notify watcher. `trigger_tx` is
597    // cloned into the callback so the async task can coalesce triggers. Keep
598    // the thread alive by blocking on a local receiver instead of repeatedly
599    // parking the thread.
600    {
601        let root = root.clone();
602        let trigger_tx_clone = trigger_tx.clone();
603
604        // Build initial Gitignore matcher once and cache it to avoid a full
605        // WalkBuilder scan on every notify event. Rebuild the matcher only when
606        // a .gitignore (or .git/info/exclude) file changes.
607        fn build_gitignore_for_root(root: &Path) -> ignore::gitignore::Gitignore {
608            let mut gbuilder = GitignoreBuilder::new(root);
609            let mut walker = WalkBuilder::new(root);
610            walker.hidden(false).git_ignore(false).git_exclude(false);
611            for entry in walker.build().filter_map(|r| r.ok()) {
612                if entry.path().file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
613                    let _ = gbuilder.add(entry.path());
614                }
615            }
616            // Also include .git/info/exclude when present
617            let git_info_exclude = root.join(".git").join("info").join("exclude");
618            if git_info_exclude.is_file() {
619                let _ = gbuilder.add(git_info_exclude);
620            }
621            match gbuilder.build() {
622                Ok(g) => g,
623                Err(_) => ignore::gitignore::Gitignore::empty(),
624            }
625        }
626
627        let cached_gi = std::sync::Arc::new(std::sync::Mutex::new(build_gitignore_for_root(&root)));
628
629        std::thread::spawn(move || {
630            // Create a raw notify watcher with a callback that forwards only
631            // structural events for non-ignored paths. Sending into the async
632            // channel is cheap; the async task implements the debounce/batching
633            // semantics.
634            let watcher_root = root.clone();
635            let cached_gi = cached_gi.clone();
636            let mut watcher = match notify::RecommendedWatcher::new(
637                move |res: Result<notify::Event, notify::Error>| {
638                    match res {
639                        Ok(event) => {
640                            // If a .gitignore or .git/info/exclude changed, rebuild cached matcher.
641                            let mut rebuild = false;
642                            for p in &event.paths {
643                                if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
644                                    rebuild = true;
645                                    break;
646                                }
647                                if p.to_string_lossy().ends_with(".git/info/exclude") {
648                                    rebuild = true;
649                                    break;
650                                }
651                            }
652                            if rebuild {
653                                let new_gi = build_gitignore_for_root(&watcher_root);
654                                if let Ok(mut guard) = cached_gi.lock() {
655                                    *guard = new_gi;
656                                }
657                            }
658
659                            // Use cached matcher to decide relevance without a full walk.
660                            let mut any_relevant = false;
661                            for p in &event.paths {
662                                let is_dir = match p.metadata() {
663                                    Ok(md) => md.is_dir(),
664                                    Err(_) => p.extension().is_none(),
665                                };
666                                let guard = cached_gi.lock().unwrap();
667                                if !guard.matched(p, is_dir).is_ignore() {
668                                    any_relevant = true;
669                                    break;
670                                }
671                            }
672                            if !any_relevant {
673                                return;
674                            }
675
676                            if should_trigger_notify_event(&event.kind) {
677                                // best-effort send; ignore errors (receiver closed)
678                                let _ = trigger_tx_clone.send(());
679                            }
680                        }
681                        Err(e) => log::warn!("file watcher error: {e}"),
682                    }
683                },
684                notify::Config::default(),
685            ) {
686                Ok(w) => w,
687                Err(e) => {
688                    log::warn!("file watcher: failed to create watcher: {e}");
689                    return;
690                }
691            };
692
693            if let Err(e) = watcher.watch(&root, notify::RecursiveMode::Recursive) {
694                log::warn!("file watcher: failed to watch {root:?}: {e}");
695                return;
696            }
697
698            // Block the thread indefinitely. Keeping the watcher value in
699            // scope keeps the underlying watcher active.
700            let (_tx_keepalive, rx_keepalive) = std::sync::mpsc::channel::<()>();
701            let _ = rx_keepalive.recv();
702        });
703    }
704
705    // Async task coalesces triggers and schedules index rebuilds. This
706    // implements the "Option B" debounce strategy: drain any already queued
707    // triggers, wait briefly for the filesystem to settle, drain again, then
708    // perform a single rebuild for the burst.
709    tokio::spawn(async move {
710        let mut rebuild: Option<tokio::task::JoinHandle<()>> = None;
711        while trigger_rx.recv().await.is_some() {
712            // Drain any triggers that were queued before we woke up.
713            while trigger_rx.try_recv().is_ok() {}
714
715            // Wait briefly so related events (rename/create/remove etc.) can
716            // arrive — this collapses bursts into a single rebuild.
717            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
718
719            // Drain anything that arrived while we were sleeping.
720            while trigger_rx.try_recv().is_ok() {}
721
722            // Abort any in-progress rebuild and request a fresh one for the
723            // current filesystem snapshot.
724            if let Some(h) = rebuild.take() {
725                h.abort();
726            }
727            let idx = index.clone();
728            let r = root.clone();
729            rebuild = Some(tokio::task::spawn_blocking(move || {
730                idx.store(Arc::new(Some(FileIndex::build(&r))));
731            }));
732        }
733    });
734}
735
736// ── Fuzzy search ─────────────────────────────────────────────────────────────
737
738/// Stateful fuzzy search engine backed by `nucleo_matcher`.
739///
740/// Create one instance per search task; the `Matcher`'s internal scratch
741/// buffer is reused across calls to `search_top` within the same task.
742pub struct NucleoSearch {
743    matcher: Matcher,
744}
745
746impl std::fmt::Debug for NucleoSearch {
747    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748        f.write_str("NucleoSearch")
749    }
750}
751
752impl Default for NucleoSearch {
753    fn default() -> Self {
754        Self::new()
755    }
756}
757
758impl NucleoSearch {
759    pub fn new() -> Self {
760        Self {
761            matcher: Matcher::new(Config::DEFAULT),
762        }
763    }
764
765    /// Fuzzy-search `index` for `query`, returning at most `max` entries
766    /// sorted best-first.
767    ///
768    /// Uses a trigram prefilter to reduce the candidate set, then maintains a
769    /// bounded min-heap of size `max` so the full candidate list is never
770    /// sorted — only the final top-K are extracted and sorted once.
771    pub fn search_top<'a>(
772        &mut self,
773        index: &'a FileIndex,
774        query: &str,
775        max: usize,
776    ) -> Vec<&'a FileEntry> {
777        if query.is_empty() {
778            return index.files.iter().take(max).collect();
779        }
780
781        let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
782
783        let mut heap: BinaryHeap<(Reverse<u32>, usize)> = BinaryHeap::with_capacity(max);
784
785        // Helper closure to score a candidate file index.
786        let mut process_candidate = |fi: usize| {
787            let entry = &index.files[fi];
788
789            let Some(score) = pattern.score(entry.utf32.slice(..), &mut self.matcher) else {
790                return;
791            };
792
793            if heap.len() < max {
794                heap.push((Reverse(score), fi));
795            } else if let Some(&(Reverse(min_score), _)) = heap.peek()
796                && score > min_score {
797                    heap.pop();
798                    heap.push((Reverse(score), fi));
799                }
800        };
801
802        // Use trigram prefilter when possible
803        if let Some(indices) = index.trigram_candidate_indices(query) {
804            for fi in indices {
805                process_candidate(fi);
806            }
807        } else {
808            for fi in 0..index.files.len() {
809                process_candidate(fi);
810            }
811        }
812
813        // Extract and sort results
814        let mut results: Vec<(u32, usize)> = heap
815            .into_iter()
816            .map(|(Reverse(score), idx)| (score, idx))
817            .collect();
818
819        results.sort_unstable_by_key(|b| std::cmp::Reverse(b.0));
820
821        results
822            .into_iter()
823            .map(|(_, idx)| &index.files[idx])
824            .collect()
825    }
826
827    /// Convenience wrapper returning all results.
828    #[allow(dead_code)]
829    pub fn search<'a>(&mut self, index: &'a FileIndex, query: &str) -> Vec<&'a FileEntry> {
830        self.search_top(index, query, usize::MAX)
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use notify::event::{CreateKind, DataChange, ModifyKind, RenameMode};
838    use notify::Event;
839    use std::path::PathBuf;
840    use tempfile::tempdir;
841    use std::fs::{create_dir_all, write};
842
843    #[test]
844    fn should_trigger_on_create() {
845        let ev = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![PathBuf::from("a")], attrs: Default::default() };
846        assert!(should_trigger_notify_event(&ev.kind));
847    }
848
849    #[test]
850    fn should_ignore_modify_data() {
851        let ev = Event { kind: notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)), paths: vec![PathBuf::from("a")], attrs: Default::default() };
852        assert!(!should_trigger_notify_event(&ev.kind));
853    }
854
855    #[test]
856    fn should_trigger_rename() {
857        let ev = Event { kind: notify::EventKind::Modify(ModifyKind::Name(RenameMode::Both)), paths: vec![PathBuf::from("a")], attrs: Default::default() };
858        assert!(should_trigger_notify_event(&ev.kind));
859    }
860
861    #[test]
862    fn shallow_build_limits_depth() {
863        let td = tempdir().unwrap();
864        let root = td.path();
865        create_dir_all(root.join("a/b")).unwrap();
866        write(root.join("file1.txt"), b"").unwrap();
867        write(root.join("a").join("file2.txt"), b"").unwrap();
868        write(root.join("a").join("b").join("file3.txt"), b"").unwrap();
869
870        let idx_full = FileIndex::build_with_max_depth(root, None);
871        let paths_full: Vec<String> = idx_full.files.iter().map(|e| e.path.to_string_lossy().replace('\\', "/").to_string()).collect();
872        assert!(paths_full.iter().any(|p| p == "file1.txt"));
873        assert!(paths_full.iter().any(|p| p == "a/file2.txt"));
874        assert!(paths_full.iter().any(|p| p == "a/b/file3.txt"));
875
876        let idx_shallow = FileIndex::build_with_max_depth(root, Some(1));
877        let paths_shallow: Vec<String> = idx_shallow.files.iter().map(|e| e.path.to_string_lossy().replace('\\', "/").to_string()).collect();
878        assert!(paths_shallow.iter().any(|p| p == "file1.txt"));
879        assert!(!paths_shallow.iter().any(|p| p == "a/file2.txt"));
880        assert!(!paths_shallow.iter().any(|p| p == "a/b/file3.txt"));
881    }
882
883    #[test]
884    fn walkbuilder_respects_gitignore() {
885        let td = tempdir().unwrap();
886        let root = td.path();
887        // create .gitignore listing ignored.txt
888        write(root.join(".gitignore"), b"ignored.txt\n").unwrap();
889        write(root.join("ignored.txt"), b"").unwrap();
890        write(root.join("not_ignored.txt"), b"").unwrap();
891
892        // event for ignored file should be filtered out
893        let ev_ignored = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![root.join("ignored.txt")], attrs: Default::default() };
894        assert!(!is_event_relevant(root, &ev_ignored));
895
896        // event for not ignored file should be relevant
897        let ev_not = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![root.join("not_ignored.txt")], attrs: Default::default() };
898        assert!(is_event_relevant(root, &ev_not));
899    }
900
901    // ── ProjectFileRegistry tests ─────────────────────────────────────────────
902
903    fn make_registry_fixture(root: &std::path::Path) {
904        // Layout:
905        //   root/
906        //     .git/           (makes ignore crate treat this as a git root)
907        //     src/
908        //       main.rs
909        //       lib.rs
910        //     docs/
911        //       guide.md
912        //     Cargo.toml
913        //     .gitignore  (ignores "ignored/")
914        //     ignored/
915        //       secret.txt
916        create_dir_all(root.join(".git")).unwrap(); // needed for gitignore to be applied
917        create_dir_all(root.join("src")).unwrap();
918        create_dir_all(root.join("docs")).unwrap();
919        create_dir_all(root.join("ignored")).unwrap();
920        write(root.join("src/main.rs"), b"fn main() {}").unwrap();
921        write(root.join("src/lib.rs"), b"").unwrap();
922        write(root.join("docs/guide.md"), b"# guide").unwrap();
923        write(root.join("Cargo.toml"), b"[package]").unwrap();
924        write(root.join(".gitignore"), b"ignored/\n").unwrap();
925        write(root.join("ignored/secret.txt"), b"").unwrap();
926    }
927
928    #[test]
929    fn registry_build_single_pass() {
930        let td = tempdir().unwrap();
931        let root = td.path();
932        make_registry_fixture(root);
933
934        let reg = ProjectFileRegistry::build(root);
935
936        // Basic file list
937        let file_paths: Vec<String> = reg
938            .files()
939            .iter()
940            .map(|e| e.path.to_string_lossy().replace('\\', "/").to_string())
941            .collect();
942
943        assert!(file_paths.iter().any(|p| p == "src/main.rs"), "expected src/main.rs");
944        assert!(file_paths.iter().any(|p| p == "src/lib.rs"), "expected src/lib.rs");
945        assert!(file_paths.iter().any(|p| p == "docs/guide.md"), "expected docs/guide.md");
946        assert!(file_paths.iter().any(|p| p == "Cargo.toml"), "expected Cargo.toml");
947
948        // Gitignored file must be absent
949        assert!(
950            !file_paths.iter().any(|p| p.contains("secret")),
951            "gitignored file must not appear"
952        );
953    }
954
955    #[test]
956    fn registry_children_of_returns_sorted_entries() {
957        let td = tempdir().unwrap();
958        let root = td.path();
959        make_registry_fixture(root);
960
961        let reg = ProjectFileRegistry::build(root);
962
963        // children of "src": lib.rs and main.rs (both files, sorted alpha)
964        let src_children = reg.children_of(Path::new("src"));
965        assert_eq!(src_children.len(), 2, "src should have 2 children");
966        assert!(!src_children[0].is_dir);
967        assert!(!src_children[1].is_dir);
968        let names: Vec<&str> = src_children
969            .iter()
970            .map(|e| e.path.file_name().unwrap().to_str().unwrap())
971            .collect();
972        assert_eq!(names, vec!["lib.rs", "main.rs"], "should be alphabetically sorted");
973    }
974
975    #[test]
976    fn registry_root_children_dirs_before_files() {
977        let td = tempdir().unwrap();
978        let root = td.path();
979        make_registry_fixture(root);
980
981        let reg = ProjectFileRegistry::build(root);
982        let root_ch = reg.root_children();
983
984        // Dirs come before files
985        let mut saw_file = false;
986        for entry in root_ch {
987            if entry.is_dir {
988                assert!(!saw_file, "all dirs must appear before any file");
989            } else {
990                saw_file = true;
991            }
992        }
993        assert!(saw_file, "root should contain at least one file");
994
995        // The dirs (docs, src) must be present; ignored/ must not
996        let dir_names: Vec<&str> = root_ch
997            .iter()
998            .filter(|e| e.is_dir)
999            .map(|e| e.path.file_name().unwrap().to_str().unwrap())
1000            .collect();
1001        assert!(dir_names.contains(&"src"));
1002        assert!(dir_names.contains(&"docs"));
1003        assert!(!dir_names.contains(&"ignored"), "gitignored dir must not appear");
1004    }
1005
1006    #[test]
1007    fn registry_files_under_returns_only_prefix_matches() {
1008        let td = tempdir().unwrap();
1009        let root = td.path();
1010        make_registry_fixture(root);
1011
1012        let reg = ProjectFileRegistry::build(root);
1013
1014        let under_src = reg.files_under(Path::new("src"));
1015        assert_eq!(under_src.len(), 2, "only src/* files");
1016        for p in &under_src {
1017            assert!(p.starts_with("src"), "all paths must start with src");
1018        }
1019
1020        let under_docs = reg.files_under(Path::new("docs"));
1021        assert_eq!(under_docs.len(), 1);
1022        assert!(under_docs[0].ends_with("guide.md"));
1023
1024        // Sibling dir must not appear in under_src
1025        assert!(!under_src.iter().any(|p| p.starts_with("docs")));
1026    }
1027
1028    #[test]
1029    fn registry_children_of_nonexistent_dir_returns_empty() {
1030        let td = tempdir().unwrap();
1031        let root = td.path();
1032        make_registry_fixture(root);
1033
1034        let reg = ProjectFileRegistry::build(root);
1035        // "nonexistent" is not an indexed directory
1036        let ch = reg.children_of(Path::new("nonexistent"));
1037        assert!(ch.is_empty(), "must return empty slice, not panic");
1038    }
1039
1040    #[test]
1041    fn registry_contains_is_o1() {
1042        let td = tempdir().unwrap();
1043        let root = td.path();
1044        make_registry_fixture(root);
1045
1046        let reg = ProjectFileRegistry::build(root);
1047        assert!(reg.contains(Path::new("src/main.rs")));
1048        assert!(reg.contains(Path::new("Cargo.toml")));
1049        assert!(!reg.contains(Path::new("does/not/exist.rs")));
1050        // gitignored file must not be present
1051        assert!(!reg.contains(Path::new("ignored/secret.txt")));
1052    }
1053}