Skip to main content

vtcode_indexer/
file_search.rs

1//! Fast fuzzy file search library for VT Code.
2//!
3//! Uses the `ignore` crate (same as ripgrep) for parallel directory traversal
4//! and `nucleo-matcher` for fuzzy matching.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use std::num::NonZero;
10//! use std::path::Path;
11//! use std::sync::Arc;
12//! use std::sync::atomic::AtomicBool;
13//! use vtcode_indexer::file_search::run;
14//!
15//! let results = run(
16//!     "main",
17//!     NonZero::new(100).unwrap(),
18//!     Path::new("."),
19//!     vec![],
20//!     NonZero::new(4).unwrap(),
21//!     Arc::new(AtomicBool::new(false)),
22//!     false,
23//!     true,
24//! )?;
25//!
26//! for m in results.matches {
27//!     println!("{}: {}", m.path, m.score);
28//! }
29//! # Ok::<(), anyhow::Error>(())
30//! ```
31
32use parking_lot::Mutex;
33use serde::{Deserialize, Serialize};
34use std::cmp::Reverse;
35use std::collections::BinaryHeap;
36use std::num::NonZero;
37use std::path::Path;
38use std::sync::Arc;
39use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
40use tokio::sync::RwLock;
41
42use rayon::prelude::*;
43
44/// Pre-computed file index for instant queries.
45///
46/// This index is built in the background and cached to avoid
47/// repeated directory traversals on every search.
48pub struct FileIndex {
49    /// All file paths in the workspace
50    files: Vec<String>,
51    /// All directory paths in the workspace
52    directories: Vec<String>,
53    /// When this index was last built
54    last_built: std::time::Instant,
55}
56
57/// Build a parallel walker with the given configuration.
58fn build_parallel_walker(
59    search_directory: &Path,
60    exclude: &[String],
61    threads: usize,
62    respect_gitignore: bool,
63) -> anyhow::Result<ignore::WalkParallel> {
64    let mut walk_builder = ignore::WalkBuilder::new(search_directory);
65    vtcode_commons::walk::apply_defaults(&mut walk_builder);
66
67    // File-search-specific overrides
68    walk_builder.threads(threads);
69    walk_builder.follow_links(true); // Search follows symlinks
70    walk_builder.require_git(false); // Search works outside git repos
71
72    if !respect_gitignore {
73        walk_builder
74            .git_ignore(false)
75            .git_global(false)
76            .git_exclude(false)
77            .ignore(false)
78            .parents(false);
79    }
80
81    if !exclude.is_empty() {
82        let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
83        for exclude_pattern in exclude {
84            let pattern = format!("!{exclude_pattern}");
85            override_builder.add(&pattern)?;
86        }
87        walk_builder.overrides(override_builder.build()?);
88    }
89
90    Ok(walk_builder.build_parallel())
91}
92
93impl FileIndex {
94    /// Build a file index by traversing the directory tree.
95    /// This is expensive but only done once.
96    fn build_from_directory(
97        search_directory: &Path,
98        exclude: &[String],
99        respect_gitignore: bool,
100        threads: usize,
101    ) -> anyhow::Result<Self> {
102        let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore)?;
103
104        // Collect all files and directories
105        let files_arc = Arc::new(Mutex::new(Vec::new()));
106        let dirs_arc = Arc::new(Mutex::new(Vec::new()));
107
108        walker.run(|| {
109            let files_clone = files_arc.clone();
110            let dirs_clone = dirs_arc.clone();
111            let search_dir = search_directory.to_path_buf();
112
113            Box::new(move |result| {
114                let entry = match result {
115                    Ok(e) => e,
116                    Err(_) => return ignore::WalkState::Continue,
117                };
118
119                // Make path relative to search directory
120                if let Some(rel_path) = entry
121                    .path()
122                    .strip_prefix(&search_dir)
123                    .ok()
124                    .and_then(|p| p.to_str())
125                    && !rel_path.is_empty()
126                {
127                    if entry.path().is_dir() {
128                        dirs_clone.lock().push(rel_path.to_string());
129                    } else {
130                        files_clone.lock().push(rel_path.to_string());
131                    }
132                }
133
134                ignore::WalkState::Continue
135            })
136        });
137
138        let files = Arc::try_unwrap(files_arc)
139            .map_err(|arc| {
140                anyhow::anyhow!(
141                    "failed to unwrap files arc, {} references remain",
142                    Arc::strong_count(&arc)
143                )
144            })?
145            .into_inner();
146        let directories = Arc::try_unwrap(dirs_arc)
147            .map_err(|arc| {
148                anyhow::anyhow!(
149                    "failed to unwrap dirs arc, {} references remain",
150                    Arc::strong_count(&arc)
151                )
152            })?
153            .into_inner();
154
155        Ok(Self {
156            files,
157            directories,
158            last_built: std::time::Instant::now(),
159        })
160    }
161
162    /// Query the index for matching paths.
163    /// Much faster than re-traversing the filesystem.
164    fn query(
165        &self,
166        pattern_text: &str,
167        limit: usize,
168        match_type_filter: Option<MatchType>,
169    ) -> Vec<(u32, String, MatchType)> {
170        // `query` stays serial and declarative: the parallel scoring strategy
171        // is isolated behind `score_paths_top_k`, and the per-chunk top-K heaps
172        // are merged by the shared `merge_top_k` helper. This keeps the index
173        // query logic testable without a rayon runtime in the loop.
174        let mut heaps = Vec::new();
175
176        if match_type_filter.is_none_or(|t| t == MatchType::File) {
177            heaps.push(score_paths_top_k(
178                &self.files,
179                limit,
180                pattern_text,
181                MatchType::File,
182            ));
183        }
184
185        if match_type_filter.is_none_or(|t| t == MatchType::Directory) {
186            heaps.push(score_paths_top_k(
187                &self.directories,
188                limit,
189                pattern_text,
190                MatchType::Directory,
191            ));
192        }
193
194        merge_top_k(heaps, limit)
195            .into_sorted_vec()
196            .into_iter()
197            .map(|Reverse(item)| item)
198            .collect()
199    }
200}
201
202/// Score `paths` in parallel rayon chunks, returning the worker-merged top-K
203/// heap for `match_type`.
204///
205/// This is the single boundary for the parallel scoring strategy: each worker
206/// thread gets its own `BestMatchesList` (matcher + haystack buffer reused via
207/// `map_init`), keeps its own top-K heap, and the partial heaps are merged by
208/// `merge_top_k`. Callers must not depend on equal-score ordering.
209fn score_paths_top_k(
210    paths: &[String],
211    limit: usize,
212    pattern_text: &str,
213    match_type: MatchType,
214) -> BinaryHeap<Reverse<(u32, String, MatchType)>> {
215    const CHUNK: usize = 1024;
216
217    // Serial fast path for small inputs: avoids the rayon thread-pool spawn
218    // overhead and keeps equal-score ordering deterministic.
219    if paths.len() <= CHUNK {
220        let mut list = BestMatchesList::new(limit, pattern_text);
221        for path in paths {
222            list.record_match(path, match_type);
223        }
224        return list.matches;
225    }
226
227    let heaps: Vec<_> = paths
228        .par_chunks(CHUNK)
229        .map_init(
230            || BestMatchesList::new(limit, pattern_text),
231            |list, chunk| {
232                for path in chunk {
233                    list.record_match(path, match_type);
234                }
235                std::mem::take(&mut list.matches)
236            },
237        )
238        .collect();
239
240    merge_top_k(heaps, limit)
241}
242
243/// Merge worker-local top-K heaps into a single top-K heap.
244///
245/// Because each input heap already holds only its own highest-scoring `limit`
246/// entries, the global top-K is a subset of their union; merging and re-keeping
247/// the top-K yields the correct global result.
248fn merge_top_k(
249    heaps: Vec<BinaryHeap<Reverse<(u32, String, MatchType)>>>,
250    limit: usize,
251) -> BinaryHeap<Reverse<(u32, String, MatchType)>> {
252    let mut merged = BinaryHeap::with_capacity(limit);
253    for heap in heaps {
254        for Reverse(item) in heap.into_vec() {
255            push_top_match(&mut merged, limit, item.0, item.1, item.2);
256        }
257    }
258    merged
259}
260
261/// A cached file index that can be shared across searches.
262pub struct FileIndexCache {
263    cache: Arc<RwLock<Option<Arc<FileIndex>>>>,
264    search_directory: std::path::PathBuf,
265    exclude: Vec<String>,
266    respect_gitignore: bool,
267    threads: usize,
268}
269
270impl FileIndexCache {
271    pub fn new(
272        search_directory: std::path::PathBuf,
273        exclude: impl IntoIterator<Item = String>,
274        respect_gitignore: bool,
275        threads: usize,
276    ) -> Self {
277        Self {
278            cache: Arc::new(RwLock::new(None)),
279            search_directory,
280            exclude: exclude.into_iter().collect(),
281            respect_gitignore,
282            threads,
283        }
284    }
285
286    /// Get or build the file index.
287    pub async fn get_or_build(&self) -> anyhow::Result<Arc<FileIndex>> {
288        // Check if we have a cached index
289        {
290            let guard = self.cache.read().await;
291            if let Some(index) = guard.as_ref() {
292                // Check if index is stale (older than 5 minutes)
293                if index.last_built.elapsed() < std::time::Duration::from_secs(300) {
294                    return Ok(Arc::clone(index));
295                }
296            }
297        }
298
299        // Build a new index
300        let index = Arc::new(FileIndex::build_from_directory(
301            &self.search_directory,
302            &self.exclude,
303            self.respect_gitignore,
304            self.threads,
305        )?);
306
307        // Cache and return
308        {
309            let mut guard = self.cache.write().await;
310            *guard = Some(Arc::clone(&index));
311        }
312        Ok(index)
313    }
314
315    /// Force refresh the index in the background.
316    /// Returns the old index immediately while rebuilding happens asynchronously.
317    pub fn refresh_background(&self) -> Option<Arc<FileIndex>> {
318        // Build new index asynchronously
319        let search_directory = self.search_directory.clone();
320        let exclude = self.exclude.clone();
321        let respect_gitignore = self.respect_gitignore;
322        let threads = self.threads;
323        let cache = self.cache.clone();
324
325        tokio::spawn(async move {
326            match FileIndex::build_from_directory(
327                &search_directory,
328                &exclude,
329                respect_gitignore,
330                threads,
331            ) {
332                Ok(new_index) => {
333                    let mut guard = cache.write().await;
334                    *guard = Some(Arc::new(new_index));
335                }
336                Err(e) => {
337                    tracing::error!("failed to rebuild file index: {e}");
338                }
339            }
340        });
341
342        // Return old index if available
343        let guard = self.cache.blocking_read();
344        guard.as_ref().map(Arc::clone)
345    }
346
347    /// Incrementally update the index when a file change is detected.
348    /// This is faster than a full rebuild for single file changes.
349    pub fn update_file(&self, path: &str, is_added: bool) {
350        let mut guard = self.cache.blocking_write();
351        let Some(existing) = guard.take() else { return };
352
353        let mut index = Arc::try_unwrap(existing).unwrap_or_else(|arc| (*arc).clone());
354        if is_added {
355            if Path::new(path).is_dir() {
356                index.directories.push(path.to_string());
357            } else {
358                index.files.push(path.to_string());
359            }
360        } else {
361            index.files.retain(|p| p != path);
362            index.directories.retain(|p| p != path);
363        }
364        index.last_built = std::time::Instant::now();
365        *guard = Some(Arc::new(index));
366    }
367
368    /// Get the age of the current index.
369    pub async fn index_age(&self) -> Option<std::time::Duration> {
370        let guard = self.cache.read().await;
371        guard.as_ref().map(|idx| idx.last_built.elapsed())
372    }
373}
374
375// Make FileIndex cloneable
376impl Clone for FileIndex {
377    fn clone(&self) -> Self {
378        Self {
379            files: self.files.clone(),
380            directories: self.directories.clone(),
381            last_built: self.last_built,
382        }
383    }
384}
385
386/// A single file match result.
387///
388/// Fields:
389/// - `score`: Relevance score from fuzzy matching (higher is better)
390/// - `path`: Path relative to the search directory
391/// - `match_type`: Whether the match is a file or directory
392/// - `indices`: Optional character positions for highlighting matched characters
393#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
394#[serde(rename_all = "lowercase")]
395pub enum MatchType {
396    File,
397    Directory,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct FileMatch {
402    pub score: u32,
403    pub path: String,
404    pub match_type: MatchType,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub indices: Option<Vec<u32>>,
407}
408
409/// Complete search results with total match count.
410#[derive(Debug)]
411pub struct FileSearchResults {
412    pub matches: Vec<FileMatch>,
413    pub total_match_count: usize,
414}
415
416/// Configuration for file search operations.
417pub struct FileSearchConfig {
418    pub pattern_text: String,
419    pub limit: NonZero<usize>,
420    pub search_directory: std::path::PathBuf,
421    pub exclude: Vec<String>,
422    pub threads: NonZero<usize>,
423    pub cancel_flag: Arc<AtomicBool>,
424    pub compute_indices: bool,
425    pub respect_gitignore: bool,
426}
427
428pub use vtcode_commons::paths::file_name_from_path;
429
430/// Best matches list per worker thread (lock-free collection).
431///
432/// Each worker thread gets its own instance to avoid locking during
433/// directory traversal. Results are merged at the end.
434struct BestMatchesList {
435    matches: BinaryHeap<Reverse<(u32, String, MatchType)>>,
436    limit: usize,
437    matcher: nucleo_matcher::Matcher,
438    haystack_buf: Vec<char>,
439    /// Pre-computed pattern - avoids per-match UTF-32 conversion
440    pattern: PatternStorage,
441}
442
443/// Stores a pattern in the optimal form for Utf32Str creation.
444enum PatternStorage {
445    /// ASCII pattern - can be used directly with Utf32Str::Ascii
446    Ascii(Vec<u8>),
447    /// Unicode pattern - stored as chars for Utf32Str::Unicode
448    Unicode(Vec<char>),
449}
450
451impl BestMatchesList {
452    fn new(limit: usize, pattern_text: &str) -> Self {
453        // Normalize pattern to lowercase to work around a nucleo-matcher bug:
454        // its prefilter only does case-insensitive search for lowercase needle
455        // chars, not uppercase. See https://github.com/openai/codex/pull/15772.
456        let pattern = if pattern_text.is_ascii() {
457            PatternStorage::Ascii(pattern_text.to_ascii_lowercase().into_bytes())
458        } else {
459            PatternStorage::Unicode(pattern_text.to_lowercase().chars().collect())
460        };
461
462        Self {
463            matches: BinaryHeap::new(),
464            limit,
465            matcher: nucleo_matcher::Matcher::new(nucleo_matcher::Config::DEFAULT),
466            haystack_buf: Vec::with_capacity(256),
467            pattern,
468        }
469    }
470
471    /// Record a matching path while preserving the worker-local top-K heap.
472    ///
473    /// Returns true when the path matches the search pattern, even if it
474    /// does not survive the top-K cutoff.
475    fn record_match(&mut self, path: &str, match_type: MatchType) -> bool {
476        // Use pre-computed pattern directly - zero allocation per match
477        let haystack = nucleo_matcher::Utf32Str::new(path, &mut self.haystack_buf);
478        let needle = match &self.pattern {
479            PatternStorage::Ascii(bytes) => nucleo_matcher::Utf32Str::Ascii(bytes),
480            PatternStorage::Unicode(chars) => nucleo_matcher::Utf32Str::Unicode(chars),
481        };
482        let Some(score) = self.matcher.fuzzy_match(haystack, needle) else {
483            return false;
484        };
485
486        push_top_match(
487            &mut self.matches,
488            self.limit,
489            score as u32,
490            path.to_string(),
491            match_type,
492        );
493        true
494    }
495}
496
497fn push_top_match(
498    matches: &mut BinaryHeap<Reverse<(u32, String, MatchType)>>,
499    limit: usize,
500    score: u32,
501    path: String,
502    match_type: MatchType,
503) -> bool {
504    if matches.len() < limit {
505        matches.push(Reverse((score, path, match_type)));
506        return true;
507    }
508
509    let Some(min_score) = matches.peek().map(|entry| entry.0.0) else {
510        return false;
511    };
512
513    if score <= min_score {
514        return false;
515    }
516
517    matches.pop();
518    matches.push(Reverse((score, path, match_type)));
519    true
520}
521
522/// Run fuzzy file search using a pre-computed file index.
523///
524/// This is much faster than `run()` for repeated queries on the same
525/// directory because it avoids re-traversing the filesystem.
526///
527/// # Arguments
528///
529/// * `config` - File search configuration
530/// * `index_cache` - Shared cache for the pre-computed file index
531///
532/// # Returns
533///
534/// FileSearchResults containing matched files and total match count.
535pub async fn run_with_index(
536    config: FileSearchConfig,
537    index_cache: &FileIndexCache,
538) -> anyhow::Result<FileSearchResults> {
539    let limit = config.limit.get();
540    let cancel_flag = &config.cancel_flag;
541    let compute_indices = config.compute_indices;
542
543    // Get or build the file index
544    let index = index_cache.get_or_build().await?;
545
546    // Check cancellation
547    if cancel_flag.load(Ordering::Relaxed) {
548        return Ok(FileSearchResults {
549            matches: Vec::new(),
550            total_match_count: 0,
551        });
552    }
553
554    // Query the index
555    let matched_paths = index.query(&config.pattern_text, limit, None);
556    let total_match_count = matched_paths.len();
557
558    // Build final results
559    let matches = matched_paths
560        .into_iter()
561        .map(|(score, path, match_type)| FileMatch {
562            score,
563            path,
564            match_type,
565            indices: if compute_indices {
566                Some(Vec::new())
567            } else {
568                None
569            },
570        })
571        .collect();
572
573    Ok(FileSearchResults {
574        matches,
575        total_match_count,
576    })
577}
578
579/// Run fuzzy file search with parallel traversal.
580///
581/// # Arguments
582///
583/// * `config` - File search configuration containing all search parameters
584///
585/// # Returns
586///
587/// FileSearchResults containing matched files and total match count.
588pub fn run(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
589    let limit = config.limit.get();
590    let search_directory = &config.search_directory;
591    let exclude = &config.exclude;
592    let threads = config.threads.get();
593    let cancel_flag = &config.cancel_flag;
594    let compute_indices = config.compute_indices;
595    let respect_gitignore = config.respect_gitignore;
596
597    let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore)?;
598
599    // Create per-worker result collection using Arc + Mutex for thread safety.
600    // Each worker gets exactly one instance - no sharing between workers.
601    let best_matchers_per_worker: Vec<Arc<Mutex<BestMatchesList>>> = (0..threads)
602        .map(|_| {
603            Arc::new(Mutex::new(BestMatchesList::new(
604                limit,
605                &config.pattern_text,
606            )))
607        })
608        .collect();
609
610    let total_match_count = Arc::new(AtomicUsize::new(0));
611
612    // Run parallel traversal - the closure is called once per worker thread.
613    // We use a local counter to assign each worker a unique index.
614    let worker_counter = AtomicUsize::new(0);
615    let worker_count = best_matchers_per_worker.len();
616    walker.run(|| {
617        let worker_id = worker_counter.fetch_add(1, Ordering::Relaxed) % worker_count;
618        let best_list = best_matchers_per_worker[worker_id].clone();
619        let cancel_flag_clone = cancel_flag.clone();
620        let total_match_count_clone = total_match_count.clone();
621
622        Box::new(move |result| {
623            // Check cancellation flag periodically
624            if cancel_flag_clone.load(Ordering::Relaxed) {
625                return ignore::WalkState::Quit;
626            }
627
628            let entry = match result {
629                Ok(e) => e,
630                Err(_) => return ignore::WalkState::Continue,
631            };
632
633            // Make path relative to search directory
634            let relative_path = entry
635                .path()
636                .strip_prefix(search_directory)
637                .ok()
638                .and_then(|p| p.to_str());
639
640            let path_to_match = match relative_path {
641                Some(p) if !p.is_empty() => p,
642                _ => return ignore::WalkState::Continue, // Skip root and non-relative paths
643            };
644
645            let match_type = if entry.path().is_dir() {
646                MatchType::Directory
647            } else {
648                MatchType::File
649            };
650
651            // Try to add to results - no contention with other workers
652            {
653                let mut list = best_list.lock();
654                if list.record_match(path_to_match, match_type) {
655                    total_match_count_clone.fetch_add(1, Ordering::Relaxed);
656                }
657            }
658
659            ignore::WalkState::Continue
660        })
661    });
662
663    // Merge worker-local top-K heaps into one final top-K heap.
664    let worker_heaps: Vec<BinaryHeap<Reverse<(u32, String, MatchType)>>> = best_matchers_per_worker
665        .into_iter()
666        .map(|arc| std::mem::take(&mut arc.lock().matches))
667        .collect();
668    let merged_matches = merge_top_k(worker_heaps, limit);
669
670    // Build final results
671    let matches = merged_matches
672        .into_sorted_vec()
673        .into_iter()
674        .map(|Reverse((score, path, match_type))| FileMatch {
675            score,
676            path,
677            match_type,
678            indices: if compute_indices {
679                Some(Vec::new())
680            } else {
681                None
682            },
683        })
684        .collect();
685
686    Ok(FileSearchResults {
687        matches,
688        total_match_count: total_match_count.load(Ordering::Relaxed),
689    })
690}