1use 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
44pub struct FileIndex {
49 files: Vec<String>,
51 directories: Vec<String>,
53 last_built: std::time::Instant,
55}
56
57fn 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 walk_builder.threads(threads);
69 walk_builder.follow_links(true); walk_builder.require_git(false); 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 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 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 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 fn query(
165 &self,
166 pattern_text: &str,
167 limit: usize,
168 match_type_filter: Option<MatchType>,
169 ) -> Vec<(u32, String, MatchType)> {
170 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
202fn 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 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
243fn 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
261pub 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 pub async fn get_or_build(&self) -> anyhow::Result<Arc<FileIndex>> {
288 {
290 let guard = self.cache.read().await;
291 if let Some(index) = guard.as_ref() {
292 if index.last_built.elapsed() < std::time::Duration::from_secs(300) {
294 return Ok(Arc::clone(index));
295 }
296 }
297 }
298
299 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 {
309 let mut guard = self.cache.write().await;
310 *guard = Some(Arc::clone(&index));
311 }
312 Ok(index)
313 }
314
315 pub fn refresh_background(&self) -> Option<Arc<FileIndex>> {
318 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 let guard = self.cache.blocking_read();
344 guard.as_ref().map(Arc::clone)
345 }
346
347 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 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
375impl 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#[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#[derive(Debug)]
411pub struct FileSearchResults {
412 pub matches: Vec<FileMatch>,
413 pub total_match_count: usize,
414}
415
416pub 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
430struct BestMatchesList {
435 matches: BinaryHeap<Reverse<(u32, String, MatchType)>>,
436 limit: usize,
437 matcher: nucleo_matcher::Matcher,
438 haystack_buf: Vec<char>,
439 pattern: PatternStorage,
441}
442
443enum PatternStorage {
445 Ascii(Vec<u8>),
447 Unicode(Vec<char>),
449}
450
451impl BestMatchesList {
452 fn new(limit: usize, pattern_text: &str) -> Self {
453 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 fn record_match(&mut self, path: &str, match_type: MatchType) -> bool {
476 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
522pub 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 let index = index_cache.get_or_build().await?;
545
546 if cancel_flag.load(Ordering::Relaxed) {
548 return Ok(FileSearchResults {
549 matches: Vec::new(),
550 total_match_count: 0,
551 });
552 }
553
554 let matched_paths = index.query(&config.pattern_text, limit, None);
556 let total_match_count = matched_paths.len();
557
558 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
579pub 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 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 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 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 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, };
644
645 let match_type = if entry.path().is_dir() {
646 MatchType::Directory
647 } else {
648 MatchType::File
649 };
650
651 {
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 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 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}