1#![expect(
2 unused_results,
3 reason = "File-search walker and heap operations intentionally use fluent/configuration mutation for side effects."
4)]
5
6use arc_swap::ArcSwapOption;
38use parking_lot::Mutex;
39use serde::{Deserialize, Serialize};
40use std::cmp::Reverse;
41use std::collections::BinaryHeap;
42use std::num::NonZero;
43use std::path::Path;
44use std::sync::Arc;
45use std::sync::atomic::{AtomicBool, Ordering};
46use tokio::sync::RwLock;
47
48use rayon::prelude::*;
49use vtcode_commons::StringId;
50
51pub struct FileIndex {
56 files: Vec<StringId>,
57 directories: Vec<StringId>,
58 path_texts_by_id: Arc<Vec<Arc<str>>>,
62 interner: vtcode_commons::StringInterner,
64 last_built: std::time::Instant,
65}
66
67fn build_parallel_walker(
69 search_directory: &Path,
70 exclude: &[String],
71 threads: usize,
72 respect_gitignore: bool,
73 follow_links: bool,
74) -> anyhow::Result<ignore::WalkParallel> {
75 let mut walk_builder = ignore::WalkBuilder::new(search_directory);
76 vtcode_commons::walk::apply_defaults(&mut walk_builder);
77
78 walk_builder.threads(threads);
80 walk_builder.follow_links(follow_links);
81 walk_builder.require_git(false); if !respect_gitignore {
84 walk_builder
85 .git_ignore(false)
86 .git_global(false)
87 .git_exclude(false)
88 .ignore(false)
89 .parents(false);
90 }
91
92 if !exclude.is_empty() {
93 let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
94 for exclude_pattern in exclude {
95 let pattern = format!("!{exclude_pattern}");
96 override_builder.add(&pattern)?;
97 }
98 walk_builder.overrides(override_builder.build()?);
99 }
100
101 Ok(walk_builder.build_parallel())
102}
103
104impl FileIndex {
105 fn build_from_directory(
108 search_directory: &Path,
109 exclude: &[String],
110 respect_gitignore: bool,
111 threads: usize,
112 ) -> anyhow::Result<Self> {
113 let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore, true)?;
114
115 let worker_results = Arc::new(Mutex::new(Vec::new()));
117
118 walker.run(|| {
119 let search_dir = search_directory.to_path_buf();
120 let mut state = IndexWorkerGuard {
121 results: Arc::clone(&worker_results),
122 files: Vec::new(),
123 directories: Vec::new(),
124 };
125
126 Box::new(move |result| {
127 let entry = match result {
128 Ok(e) => e,
129 Err(_) => return ignore::WalkState::Continue,
130 };
131
132 if let Some(rel_path) = entry.path().strip_prefix(&search_dir).ok().and_then(|p| p.to_str())
134 && !rel_path.is_empty()
135 {
136 if entry.file_type().is_some_and(|file_type| file_type.is_dir()) {
137 state.directories.push(rel_path.to_string());
138 } else {
139 state.files.push(rel_path.to_string());
140 }
141 }
142
143 ignore::WalkState::Continue
144 })
145 });
146
147 let worker_results = Arc::try_unwrap(worker_results)
148 .map_err(|results| {
149 anyhow::anyhow!("file index worker results still referenced: {}", Arc::strong_count(&results))
150 })?
151 .into_inner();
152 let (files, directories) =
153 worker_results
154 .into_iter()
155 .fold((Vec::new(), Vec::new()), |(mut files, mut directories), result| {
156 files.extend(result.files);
157 directories.extend(result.directories);
158 (files, directories)
159 });
160
161 let mut interner = vtcode_commons::StringInterner::new();
162 let mut path_texts_by_id = Vec::with_capacity(files.len() + directories.len());
163 let interned_files: Vec<StringId> = files
164 .iter()
165 .map(|path| intern_path(path, &mut interner, &mut path_texts_by_id))
166 .collect();
167 let interned_dirs: Vec<StringId> = directories
168 .iter()
169 .map(|path| intern_path(path, &mut interner, &mut path_texts_by_id))
170 .collect();
171
172 Ok(Self {
173 files: interned_files,
174 directories: interned_dirs,
175 path_texts_by_id: Arc::new(path_texts_by_id),
176 interner,
177 last_built: std::time::Instant::now(),
178 })
179 }
180
181 fn query(
184 &self,
185 pattern_text: &str,
186 limit: usize,
187 match_type_filter: Option<MatchType>,
188 ) -> Vec<(u32, StringId, MatchType)> {
189 let mut heaps = Vec::new();
194
195 if match_type_filter.is_none_or(|t| t == MatchType::File) {
196 heaps.push(score_paths_top_k(
197 &self.files,
198 self.path_texts_by_id.as_slice(),
199 limit,
200 pattern_text,
201 MatchType::File,
202 ));
203 }
204
205 if match_type_filter.is_none_or(|t| t == MatchType::Directory) {
206 heaps.push(score_paths_top_k(
207 &self.directories,
208 self.path_texts_by_id.as_slice(),
209 limit,
210 pattern_text,
211 MatchType::Directory,
212 ));
213 }
214
215 merge_top_k(heaps, limit)
216 .into_sorted_vec()
217 .into_iter()
218 .map(|Reverse(item)| item)
219 .collect()
220 }
221}
222
223fn intern_path(
224 path: &str,
225 interner: &mut vtcode_commons::StringInterner,
226 path_texts_by_id: &mut Vec<Arc<str>>,
227) -> StringId {
228 let path_id = interner.intern(path);
229 let path_index = path_id.as_u32() as usize;
230 if path_index == path_texts_by_id.len() {
231 path_texts_by_id.push(Arc::from(path));
232 }
233 path_id
234}
235
236fn score_paths_top_k(
244 paths: &[StringId],
245 path_texts_by_id: &[Arc<str>],
246 limit: usize,
247 pattern_text: &str,
248 match_type: MatchType,
249) -> BinaryHeap<Reverse<(u32, StringId, MatchType)>> {
250 const CHUNK: usize = 1024;
251
252 if paths.len() <= CHUNK {
255 let mut list = BestMatchesList::new(limit, pattern_text);
256 for &path_id in paths {
257 if let Some(path) = path_texts_by_id.get(path_id.as_u32() as usize) {
258 list.record_match(path_id, path, match_type);
259 }
260 }
261 return list.matches;
262 }
263
264 let heaps: Vec<_> = paths
265 .par_chunks(CHUNK)
266 .map_init(
267 || BestMatchesList::new(limit, pattern_text),
268 |list, chunk| {
269 for &path_id in chunk {
270 if let Some(path) = path_texts_by_id.get(path_id.as_u32() as usize) {
271 list.record_match(path_id, path, match_type);
272 }
273 }
274 std::mem::take(&mut list.matches)
275 },
276 )
277 .collect();
278
279 merge_top_k(heaps, limit)
280}
281
282fn merge_top_k(
288 heaps: Vec<BinaryHeap<Reverse<(u32, StringId, MatchType)>>>,
289 limit: usize,
290) -> BinaryHeap<Reverse<(u32, StringId, MatchType)>> {
291 let mut merged = BinaryHeap::with_capacity(limit);
292 for heap in heaps {
293 for Reverse(item) in heap.into_vec() {
294 push_top_match(&mut merged, limit, item.0, item.1, item.2);
295 }
296 }
297 merged
298}
299
300pub struct FileIndexCache {
302 cache: Arc<RwLock<Option<Arc<FileIndex>>>>,
303 snapshot: Arc<ArcSwapOption<FileIndex>>,
307 build_gate: Arc<tokio::sync::Semaphore>,
310 search_directory: std::path::PathBuf,
311 exclude: Vec<String>,
312 respect_gitignore: bool,
313 threads: usize,
314}
315
316impl FileIndexCache {
317 pub fn new(
318 search_directory: std::path::PathBuf,
319 exclude: impl IntoIterator<Item = String>,
320 respect_gitignore: bool,
321 threads: usize,
322 ) -> Self {
323 Self {
324 cache: Arc::new(RwLock::new(None)),
325 snapshot: Arc::new(ArcSwapOption::empty()),
326 build_gate: Arc::new(tokio::sync::Semaphore::new(1)),
327 search_directory,
328 exclude: exclude.into_iter().collect(),
329 respect_gitignore,
330 threads,
331 }
332 }
333
334 pub async fn get_or_build(&self) -> anyhow::Result<Arc<FileIndex>> {
336 {
338 let guard = self.cache.read().await;
339 if let Some(index) = guard.as_ref() {
340 if index.last_built.elapsed() < std::time::Duration::from_secs(300) {
342 return Ok(Arc::clone(index));
343 }
344 }
345 }
346
347 let _build_permit = self.build_gate.acquire().await?;
350 {
351 let guard = self.cache.read().await;
352 if let Some(index) = guard.as_ref()
353 && index.last_built.elapsed() < std::time::Duration::from_secs(300)
354 {
355 return Ok(Arc::clone(index));
356 }
357 }
358
359 let search_directory = self.search_directory.clone();
363 let exclude = self.exclude.clone();
364 let respect_gitignore = self.respect_gitignore;
365 let threads = self.threads;
366 let index = Arc::new(
367 tokio::task::spawn_blocking(move || {
368 FileIndex::build_from_directory(&search_directory, &exclude, respect_gitignore, threads)
369 })
370 .await??,
371 );
372
373 {
375 let mut guard = self.cache.write().await;
376 *guard = Some(Arc::clone(&index));
377 self.snapshot.store(Some(Arc::clone(&index)));
378 }
379 Ok(index)
380 }
381
382 pub fn refresh_background(&self) -> Option<Arc<FileIndex>> {
388 let runtime = match tokio::runtime::Handle::try_current() {
389 Ok(runtime) => runtime,
390 Err(error) => {
391 tracing::debug!(%error, "cannot refresh file index without a Tokio runtime");
392 return self.snapshot.load_full();
393 }
394 };
395
396 let search_directory = self.search_directory.clone();
398 let exclude = self.exclude.clone();
399 let respect_gitignore = self.respect_gitignore;
400 let threads = self.threads;
401 let cache = self.cache.clone();
402 let snapshot = Arc::clone(&self.snapshot);
403 let build_gate = Arc::clone(&self.build_gate);
404
405 runtime.spawn(async move {
406 let _build_permit = match build_gate.acquire_owned().await {
407 Ok(permit) => permit,
408 Err(error) => {
409 tracing::error!(%error, "file index build gate closed");
410 return;
411 }
412 };
413
414 match tokio::task::spawn_blocking(move || {
415 FileIndex::build_from_directory(&search_directory, &exclude, respect_gitignore, threads)
416 })
417 .await
418 {
419 Ok(Ok(new_index)) => {
420 let new_index = Arc::new(new_index);
421 let mut guard = cache.write().await;
422 *guard = Some(Arc::clone(&new_index));
423 snapshot.store(Some(new_index));
424 }
425 Ok(Err(error)) => {
426 tracing::error!(%error, "failed to rebuild file index");
427 }
428 Err(error) => {
429 tracing::error!(%error, "file index rebuild task failed");
430 }
431 }
432 });
433
434 self.snapshot.load_full()
435 }
436
437 pub fn update_file(&self, path: &str, is_added: bool) {
440 let mut guard = self.cache.blocking_write();
441 let Some(existing) = guard.take() else { return };
442
443 let mut index = Arc::try_unwrap(existing).unwrap_or_else(|arc| (*arc).clone());
444 if is_added {
445 let path_id = intern_path(path, &mut index.interner, Arc::make_mut(&mut index.path_texts_by_id));
446 let is_directory = self.search_directory.join(path).is_dir();
447 if is_directory {
448 index.files.retain(|&existing| existing != path_id);
449 if !index.directories.contains(&path_id) {
450 index.directories.push(path_id);
451 }
452 } else {
453 index.directories.retain(|&existing| existing != path_id);
454 if !index.files.contains(&path_id) {
455 index.files.push(path_id);
456 }
457 }
458 } else {
459 let Some(path_id) = index.files.iter().chain(index.directories.iter()).copied().find(|&path_id| {
460 index
461 .path_texts_by_id
462 .get(path_id.as_u32() as usize)
463 .is_some_and(|value| value.as_ref() == path)
464 }) else {
465 let index = Arc::new(index);
466 *guard = Some(Arc::clone(&index));
467 self.snapshot.store(Some(index));
468 return;
469 };
470 index.files.retain(|&existing| existing != path_id);
471 index.directories.retain(|&existing| existing != path_id);
472 }
473 index.last_built = std::time::Instant::now();
474 let index = Arc::new(index);
475 *guard = Some(Arc::clone(&index));
476 self.snapshot.store(Some(index));
477 }
478
479 pub async fn index_age(&self) -> Option<std::time::Duration> {
481 let guard = self.cache.read().await;
482 guard.as_ref().map(|idx| idx.last_built.elapsed())
483 }
484}
485
486impl Clone for FileIndex {
488 fn clone(&self) -> Self {
489 Self {
490 files: self.files.clone(),
491 directories: self.directories.clone(),
492 path_texts_by_id: Arc::clone(&self.path_texts_by_id),
493 interner: self.interner.clone(),
494 last_built: self.last_built,
495 }
496 }
497}
498
499#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
507#[serde(rename_all = "lowercase")]
508pub enum MatchType {
509 File,
510 Directory,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct FileMatch {
515 pub score: u32,
516 pub path: String,
517 pub match_type: MatchType,
518 #[serde(skip_serializing_if = "Option::is_none")]
519 pub indices: Option<Vec<u32>>,
520}
521
522#[derive(Debug)]
524pub struct FileSearchResults {
525 pub matches: Vec<FileMatch>,
526 pub total_match_count: usize,
527}
528
529pub struct FileSearchConfig {
531 pub pattern_text: String,
532 pub limit: NonZero<usize>,
533 pub search_directory: std::path::PathBuf,
534 pub exclude: Vec<String>,
535 pub threads: NonZero<usize>,
536 pub cancel_flag: Arc<AtomicBool>,
537 pub compute_indices: bool,
538 pub respect_gitignore: bool,
539}
540
541pub use vtcode_commons::paths::file_name_from_path;
542
543struct BestMatchesList {
548 matches: BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
549 limit: usize,
550 matcher: nucleo_matcher::Matcher,
551 haystack_buf: Vec<char>,
552 pattern: PatternStorage,
554 owned_matches: BinaryHeap<Reverse<OwnedMatchCandidate>>,
555}
556
557#[derive(Debug, Eq, PartialEq)]
558struct OwnedMatchCandidate {
559 score: u32,
560 path: String,
561 match_type: MatchType,
562}
563
564impl Ord for OwnedMatchCandidate {
565 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
566 self.score
567 .cmp(&other.score)
568 .then_with(|| other.path.cmp(&self.path))
569 .then_with(|| other.match_type.cmp(&self.match_type))
570 }
571}
572
573impl PartialOrd for OwnedMatchCandidate {
574 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
575 Some(self.cmp(other))
576 }
577}
578
579struct SearchAggregation {
580 matches: BinaryHeap<Reverse<OwnedMatchCandidate>>,
581 total_match_count: usize,
582}
583
584struct IndexWorkerResult {
585 files: Vec<String>,
586 directories: Vec<String>,
587}
588
589struct IndexWorkerGuard {
590 results: Arc<Mutex<Vec<IndexWorkerResult>>>,
591 files: Vec<String>,
592 directories: Vec<String>,
593}
594
595impl Drop for IndexWorkerGuard {
596 fn drop(&mut self) {
597 self.results.lock().push(IndexWorkerResult {
598 files: std::mem::take(&mut self.files),
599 directories: std::mem::take(&mut self.directories),
600 });
601 }
602}
603
604struct SearchWorkerGuard {
605 results: Arc<Mutex<SearchAggregation>>,
606 best_list: BestMatchesList,
607 total_match_count: usize,
608 limit: usize,
609}
610
611impl Drop for SearchWorkerGuard {
612 fn drop(&mut self) {
613 let worker_matches = std::mem::take(&mut self.best_list.owned_matches);
614 let mut results = self.results.lock();
615 results.total_match_count += self.total_match_count;
616 for Reverse(candidate) in worker_matches {
617 push_owned_match(&mut results.matches, self.limit, candidate);
618 }
619 }
620}
621
622enum PatternStorage {
624 Ascii(Vec<u8>),
626 Unicode(Vec<char>),
628}
629
630impl BestMatchesList {
631 fn new(limit: usize, pattern_text: &str) -> Self {
632 let pattern = if pattern_text.is_ascii() {
636 PatternStorage::Ascii(pattern_text.to_ascii_lowercase().into_bytes())
637 } else {
638 PatternStorage::Unicode(pattern_text.to_lowercase().chars().collect())
639 };
640
641 Self {
642 matches: BinaryHeap::new(),
643 limit,
644 matcher: nucleo_matcher::Matcher::new(nucleo_matcher::Config::DEFAULT),
645 haystack_buf: Vec::with_capacity(256),
646 pattern,
647 owned_matches: BinaryHeap::new(),
648 }
649 }
650
651 fn score_path(&mut self, path: &str) -> Option<u32> {
654 let haystack = nucleo_matcher::Utf32Str::new(path, &mut self.haystack_buf);
655 let needle = match &self.pattern {
656 PatternStorage::Ascii(bytes) => nucleo_matcher::Utf32Str::Ascii(bytes),
657 PatternStorage::Unicode(chars) => nucleo_matcher::Utf32Str::Unicode(chars),
658 };
659 self.matcher.fuzzy_match(haystack, needle).map(|score| score as u32)
660 }
661
662 fn record_match(&mut self, path_id: StringId, path: &str, match_type: MatchType) -> bool {
664 let Some(score) = self.score_path(path) else {
665 return false;
666 };
667 push_top_match(&mut self.matches, self.limit, score, path_id, match_type);
668 true
669 }
670
671 fn record_scored_match(&mut self, path_id: StringId, score: u32, match_type: MatchType) {
672 push_top_match(&mut self.matches, self.limit, score, path_id, match_type);
673 }
674
675 fn record_owned_match(&mut self, path: &str, score: u32, match_type: MatchType) {
676 if self.owned_matches.len() == self.limit {
677 let Some(Reverse(worst)) = self.owned_matches.peek() else {
678 return;
679 };
680 if !owned_match_is_better(score, path, match_type, worst) {
681 return;
682 }
683 }
684
685 push_owned_match(
686 &mut self.owned_matches,
687 self.limit,
688 OwnedMatchCandidate { score, path: path.to_owned(), match_type },
689 );
690 }
691}
692
693fn push_owned_match(
694 matches: &mut BinaryHeap<Reverse<OwnedMatchCandidate>>,
695 limit: usize,
696 candidate: OwnedMatchCandidate,
697) {
698 if matches.len() == limit {
699 let Some(Reverse(worst)) = matches.peek() else {
700 return;
701 };
702 if !owned_match_is_better(candidate.score, candidate.path.as_str(), candidate.match_type, worst) {
703 return;
704 }
705 matches.pop();
706 }
707
708 matches.push(Reverse(candidate));
709}
710
711fn owned_match_is_better(score: u32, path: &str, match_type: MatchType, worst: &OwnedMatchCandidate) -> bool {
712 score
713 .cmp(&worst.score)
714 .then_with(|| worst.path.as_str().cmp(path))
715 .then_with(|| worst.match_type.cmp(&match_type))
716 .is_gt()
717}
718
719fn push_top_match(
720 matches: &mut BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
721 limit: usize,
722 score: u32,
723 path: StringId,
724 match_type: MatchType,
725) -> bool {
726 let candidate = (score, path, match_type);
727 if matches.len() < limit {
728 matches.push(Reverse(candidate));
729 return true;
730 }
731
732 let Some(minimum) = matches.peek().map(|entry| &entry.0) else {
733 return false;
734 };
735
736 if &candidate <= minimum {
737 return false;
738 }
739
740 matches.pop();
741 matches.push(Reverse(candidate));
742 true
743}
744
745pub async fn run_with_index(
759 config: FileSearchConfig,
760 index_cache: &FileIndexCache,
761) -> anyhow::Result<FileSearchResults> {
762 let limit = config.limit.get();
763 let cancel_flag = &config.cancel_flag;
764 let compute_indices = config.compute_indices;
765
766 let index = index_cache.get_or_build().await?;
768
769 if cancel_flag.load(Ordering::Relaxed) {
771 return Ok(FileSearchResults { matches: Vec::new(), total_match_count: 0 });
772 }
773
774 let index_for_results = index.clone();
777 let matched_paths = tokio::task::spawn_blocking({
778 let pattern_text = config.pattern_text.clone();
779 move || Ok::<_, anyhow::Error>(index.query(&pattern_text, limit, None))
780 })
781 .await??;
782
783 let total_match_count = matched_paths.len();
784
785 let matches = matched_paths
787 .into_iter()
788 .filter_map(|(score, path_id, match_type)| {
789 let path = index_for_results.path_texts_by_id.get(path_id.as_u32() as usize)?.to_string();
790 Some(FileMatch {
791 score,
792 path,
793 match_type,
794 indices: if compute_indices { Some(Vec::new()) } else { None },
795 })
796 })
797 .collect();
798
799 Ok(FileSearchResults { matches, total_match_count })
800}
801
802pub fn run(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
812 run_with_policy(config, true, false)
813}
814
815pub fn run_bounded_no_follow(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
821 run_bounded_no_follow_with_visit(config, |_| {})
822}
823
824fn run_bounded_no_follow_with_visit(
825 config: FileSearchConfig,
826 mut visit: impl FnMut(&Path),
827) -> anyhow::Result<FileSearchResults> {
828 let limit = config.limit.get();
829 let search_directory = &config.search_directory;
830 let mut walk_builder = ignore::WalkBuilder::new(search_directory);
831 vtcode_commons::walk::apply_defaults(&mut walk_builder);
832 walk_builder
833 .follow_links(false)
834 .require_git(false)
835 .sort_by_file_path(|left, right| left.cmp(right));
836
837 if !config.respect_gitignore {
838 walk_builder
839 .git_ignore(false)
840 .git_global(false)
841 .git_exclude(false)
842 .ignore(false)
843 .parents(false);
844 }
845
846 if !config.exclude.is_empty() {
847 let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
848 for exclude_pattern in &config.exclude {
849 override_builder.add(&format!("!{exclude_pattern}"))?;
850 }
851 walk_builder.overrides(override_builder.build()?);
852 }
853
854 let interner = Arc::new(Mutex::new(vtcode_commons::StringInterner::new()));
855 let mut matches = BestMatchesList::new(limit, &config.pattern_text);
856 let mut matching_count = 0usize;
857 for result in walk_builder.build() {
858 if config.cancel_flag.load(Ordering::Relaxed) {
859 break;
860 }
861 let entry = match result {
862 Ok(entry) => entry,
863 Err(_) => continue,
864 };
865 visit(entry.path());
866 if !entry.file_type().is_some_and(|file_type| file_type.is_file()) {
867 continue;
868 }
869 let Some(relative_path) = entry
870 .path()
871 .strip_prefix(search_directory)
872 .ok()
873 .and_then(|path| path.to_str())
874 .filter(|path| !path.is_empty())
875 else {
876 continue;
877 };
878 let Some(score) = matches.score_path(relative_path) else {
879 continue;
880 };
881 let path_id = interner.lock().intern(relative_path);
882 matches.record_scored_match(path_id, score, MatchType::File);
883 matching_count += 1;
884 if matching_count >= limit {
885 break;
886 }
887 }
888
889 let interner_guard = interner.lock();
890 let matches = matches
891 .matches
892 .into_sorted_vec()
893 .into_iter()
894 .filter_map(|Reverse((score, path_id, match_type))| {
895 let path = interner_guard.get(path_id)?.to_string();
896 Some(FileMatch {
897 score,
898 path,
899 match_type,
900 indices: config.compute_indices.then(Vec::new),
901 })
902 })
903 .collect();
904
905 Ok(FileSearchResults {
906 matches,
907 total_match_count: matching_count + usize::from(matching_count >= limit),
910 })
911}
912
913fn run_with_policy(
914 config: FileSearchConfig,
915 follow_links: bool,
916 files_only: bool,
917) -> anyhow::Result<FileSearchResults> {
918 let limit = config.limit.get();
919 let search_directory = &config.search_directory;
920 let exclude = &config.exclude;
921 let threads = config.threads.get();
922 let cancel_flag = &config.cancel_flag;
923 let compute_indices = config.compute_indices;
924 let respect_gitignore = config.respect_gitignore;
925
926 let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore, follow_links)?;
927
928 let worker_results = Arc::new(Mutex::new(SearchAggregation { matches: BinaryHeap::new(), total_match_count: 0 }));
929 walker.run(|| {
930 let mut state = SearchWorkerGuard {
931 results: Arc::clone(&worker_results),
932 best_list: BestMatchesList::new(limit, &config.pattern_text),
933 total_match_count: 0,
934 limit,
935 };
936
937 Box::new(move |result| {
938 if cancel_flag.load(Ordering::Relaxed) {
940 return ignore::WalkState::Quit;
941 }
942
943 let entry = match result {
944 Ok(e) => e,
945 Err(_) => return ignore::WalkState::Continue,
946 };
947
948 let relative_path = entry.path().strip_prefix(search_directory).ok().and_then(|p| p.to_str());
950
951 let path_to_match = match relative_path {
952 Some(p) if !p.is_empty() => p,
953 _ => return ignore::WalkState::Continue, };
955
956 let Some(file_type) = entry.file_type() else {
957 return ignore::WalkState::Continue;
958 };
959 let match_type = if file_type.is_dir() {
960 MatchType::Directory
961 } else {
962 MatchType::File
963 };
964
965 if files_only && match_type == MatchType::Directory {
966 return ignore::WalkState::Continue;
967 }
968
969 let Some(score) = state.best_list.score_path(path_to_match) else {
971 return ignore::WalkState::Continue;
972 };
973 state.best_list.record_owned_match(path_to_match, score, match_type);
974 state.total_match_count += 1;
975
976 ignore::WalkState::Continue
977 })
978 });
979
980 let results = Arc::try_unwrap(worker_results)
981 .map_err(|results| {
982 anyhow::anyhow!("file search worker results still referenced: {}", Arc::strong_count(&results))
983 })?
984 .into_inner();
985 let total_match_count = results.total_match_count;
986 let mut candidates = results
987 .matches
988 .into_iter()
989 .map(|Reverse(candidate)| candidate)
990 .collect::<Vec<_>>();
991 candidates.sort_by(|left, right| {
992 right
993 .score
994 .cmp(&left.score)
995 .then_with(|| left.path.cmp(&right.path))
996 .then_with(|| left.match_type.cmp(&right.match_type))
997 });
998 candidates.truncate(limit);
999
1000 let matches = candidates
1002 .into_iter()
1003 .map(|candidate| FileMatch {
1004 score: candidate.score,
1005 path: candidate.path,
1006 match_type: candidate.match_type,
1007 indices: if compute_indices { Some(Vec::new()) } else { None },
1008 })
1009 .collect();
1010
1011 Ok(FileSearchResults { matches, total_match_count })
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::{
1017 FileIndexCache, FileSearchConfig, MatchType, run, run_bounded_no_follow, run_bounded_no_follow_with_visit,
1018 run_with_index,
1019 };
1020 use std::num::NonZero;
1021 use std::sync::Arc;
1022 use std::sync::atomic::AtomicBool;
1023 use tempfile::TempDir;
1024
1025 #[tokio::test(flavor = "current_thread")]
1026 async fn concurrent_index_builds_share_async_cache_entry() {
1027 let workspace = TempDir::new().expect("workspace");
1028 std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1029
1030 let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), true, 1);
1031 let (first, second) = tokio::join!(cache.get_or_build(), cache.get_or_build());
1032 let first = first.expect("build file index");
1033 let second = second.expect("reuse file index");
1034
1035 assert!(Arc::ptr_eq(&first, &second));
1036 }
1037
1038 #[tokio::test(flavor = "current_thread")]
1039 async fn background_refresh_is_safe_when_called_from_tokio() {
1040 let workspace = TempDir::new().expect("workspace");
1041 std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1042
1043 let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
1044 cache.get_or_build().await.expect("initial index");
1045
1046 assert!(cache.refresh_background().is_some());
1047 }
1048
1049 #[test]
1050 fn background_refresh_returns_snapshot_without_runtime() {
1051 let workspace = TempDir::new().expect("workspace");
1052 std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1053
1054 let runtime = tokio::runtime::Runtime::new().expect("Tokio runtime");
1055 let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
1056 runtime.block_on(cache.get_or_build()).expect("initial index");
1057 drop(runtime);
1058
1059 assert!(cache.refresh_background().is_some());
1060 }
1061
1062 #[tokio::test(flavor = "current_thread")]
1063 async fn incremental_directory_updates_use_the_cache_root() {
1064 let workspace = TempDir::new().expect("workspace");
1065 std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1066 std::fs::create_dir(workspace.path().join("new_directory")).expect("fixture directory");
1067
1068 let cache = Arc::new(FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1));
1069 cache.get_or_build().await.expect("initial index");
1070
1071 tokio::task::spawn_blocking({
1072 let cache = Arc::clone(&cache);
1073 move || cache.update_file("new_directory", true)
1074 })
1075 .await
1076 .expect("incremental directory update task");
1077
1078 let index = cache.get_or_build().await.expect("updated index");
1079 let matches = index.query("new_directory", 16, None);
1080 assert!(matches.iter().any(|(_, _, match_type)| *match_type == MatchType::Directory));
1081 }
1082
1083 fn indexed_search_config(
1084 workspace: &std::path::Path,
1085 pattern: &str,
1086 cancel_flag: Arc<AtomicBool>,
1087 ) -> FileSearchConfig {
1088 FileSearchConfig {
1089 pattern_text: pattern.to_string(),
1090 limit: NonZero::new(16).expect("non-zero limit"),
1091 search_directory: workspace.to_path_buf(),
1092 exclude: Vec::new(),
1093 threads: NonZero::new(1).expect("non-zero threads"),
1094 cancel_flag,
1095 compute_indices: false,
1096 respect_gitignore: false,
1097 }
1098 }
1099
1100 fn result_signature(results: &super::FileSearchResults) -> Vec<(u32, String, MatchType)> {
1101 results
1102 .matches
1103 .iter()
1104 .map(|candidate| (candidate.score, candidate.path.clone(), candidate.match_type))
1105 .collect()
1106 }
1107
1108 #[tokio::test(flavor = "current_thread")]
1109 async fn indexed_search_preserves_scores_order_and_match_types() {
1110 let workspace = TempDir::new().expect("workspace");
1111 std::fs::create_dir_all(workspace.path().join("src/widget_dir")).expect("fixture directory");
1112 std::fs::write(workspace.path().join("src/widget.rs"), "fn widget() {}\n").expect("fixture source");
1113 std::fs::write(workspace.path().join("src/widget_test.rs"), "fn widget_test() {}\n").expect("fixture source");
1114 std::fs::write(workspace.path().join("README.md"), "widget documentation\n").expect("fixture docs");
1115
1116 let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
1117 let first =
1118 run_with_index(indexed_search_config(workspace.path(), "widget", Arc::new(AtomicBool::new(false))), &cache)
1119 .await
1120 .expect("indexed search");
1121 let second =
1122 run_with_index(indexed_search_config(workspace.path(), "widget", Arc::new(AtomicBool::new(false))), &cache)
1123 .await
1124 .expect("repeat indexed search");
1125
1126 assert_eq!(result_signature(&first), result_signature(&second));
1127 assert!(
1128 first
1129 .matches
1130 .iter()
1131 .any(|candidate| candidate.match_type == MatchType::Directory)
1132 );
1133 assert!(first.matches.iter().any(|candidate| candidate.match_type == MatchType::File));
1134 assert!(first.matches.windows(2).all(|window| window[0].score >= window[1].score));
1135 }
1136
1137 #[tokio::test(flavor = "current_thread")]
1138 async fn indexed_search_honors_cancellation_before_scoring() {
1139 let workspace = TempDir::new().expect("workspace");
1140 std::fs::write(workspace.path().join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1141 let cache = FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1);
1142 let cancel_flag = Arc::new(AtomicBool::new(true));
1143
1144 let results = run_with_index(indexed_search_config(workspace.path(), "widget", cancel_flag), &cache)
1145 .await
1146 .expect("cancelled indexed search");
1147 assert!(results.matches.is_empty());
1148 assert_eq!(results.total_match_count, 0);
1149 }
1150
1151 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1152 async fn incremental_updates_do_not_mutate_an_old_search_index() {
1153 let workspace = TempDir::new().expect("workspace");
1154 std::fs::write(workspace.path().join("old_widget.rs"), "fn old_widget() {}\n").expect("fixture source");
1155 let cache = Arc::new(FileIndexCache::new(workspace.path().to_path_buf(), Vec::new(), false, 1));
1156 let old_index = cache.get_or_build().await.expect("initial index");
1157
1158 tokio::task::spawn_blocking({
1159 let cache = Arc::clone(&cache);
1160 move || cache.update_file("new_widget.rs", true)
1161 })
1162 .await
1163 .expect("incremental update task");
1164
1165 let old_matches = old_index.query("widget", 16, None);
1166 let new_index = cache.get_or_build().await.expect("updated index");
1167 let new_matches = new_index.query("widget", 16, None);
1168 let old_paths = old_matches
1169 .iter()
1170 .filter_map(|(_, path_id, _)| old_index.path_texts_by_id.get(path_id.as_u32() as usize))
1171 .map(AsRef::as_ref)
1172 .collect::<Vec<&str>>();
1173 let new_paths = new_matches
1174 .iter()
1175 .filter_map(|(_, path_id, _)| new_index.path_texts_by_id.get(path_id.as_u32() as usize))
1176 .map(AsRef::as_ref)
1177 .collect::<Vec<&str>>();
1178
1179 assert!(!old_paths.contains(&"new_widget.rs"));
1180 assert!(new_paths.contains(&"new_widget.rs"));
1181 }
1182
1183 fn uncached_search_config(
1184 workspace: &std::path::Path,
1185 pattern: &str,
1186 limit: usize,
1187 cancel_flag: Arc<AtomicBool>,
1188 ) -> FileSearchConfig {
1189 FileSearchConfig {
1190 pattern_text: pattern.to_string(),
1191 limit: NonZero::new(limit).expect("non-zero limit"),
1192 search_directory: workspace.to_path_buf(),
1193 exclude: Vec::new(),
1194 threads: NonZero::new(4).expect("non-zero threads"),
1195 cancel_flag,
1196 compute_indices: false,
1197 respect_gitignore: false,
1198 }
1199 }
1200
1201 #[test]
1202 fn uncached_parallel_search_aggregates_worker_paths_and_counts() {
1203 let workspace = TempDir::new().expect("workspace");
1204 for index in 0..64 {
1205 std::fs::create_dir(workspace.path().join(format!("widget_dir_{index:03}"))).expect("fixture directory");
1206 std::fs::write(workspace.path().join(format!("widget_file_{index:03}.rs")), "fn widget() {}\n")
1207 .expect("fixture source");
1208 }
1209
1210 let results = run(uncached_search_config(workspace.path(), "widget", 32, Arc::new(AtomicBool::new(false))))
1211 .expect("uncached parallel search");
1212
1213 assert_eq!(results.total_match_count, 128);
1214 assert_eq!(results.matches.len(), 32);
1215 let unique_paths = results
1216 .matches
1217 .iter()
1218 .map(|candidate| candidate.path.as_str())
1219 .collect::<std::collections::HashSet<_>>();
1220 assert_eq!(unique_paths.len(), results.matches.len());
1221 assert!(results.matches.iter().all(|candidate| candidate.path.contains("widget")));
1222 }
1223
1224 #[test]
1225 fn uncached_parallel_search_honors_cancellation() {
1226 let workspace = TempDir::new().expect("workspace");
1227 for index in 0..32 {
1228 std::fs::write(workspace.path().join(format!("widget_{index:03}.rs")), "fn widget() {}\n")
1229 .expect("fixture source");
1230 }
1231 let cancel_flag = Arc::new(AtomicBool::new(true));
1232
1233 let results = run(uncached_search_config(workspace.path(), "widget", 16, cancel_flag))
1234 .expect("cancelled uncached search");
1235
1236 assert!(results.matches.is_empty());
1237 assert_eq!(results.total_match_count, 0);
1238 }
1239
1240 #[test]
1241 fn uncached_equal_score_results_are_lexically_ordered_and_repeatable() {
1242 let workspace = TempDir::new().expect("workspace");
1243 for name in ["a_x.rs", "b_x.rs", "c_x.rs", "d_x.rs", "e_x.rs"] {
1244 std::fs::write(workspace.path().join(name), "fn x() {}\n").expect("fixture source");
1245 }
1246
1247 let search = || {
1248 run(uncached_search_config(workspace.path(), "x", 3, Arc::new(AtomicBool::new(false))))
1249 .expect("uncached equal-score search")
1250 .matches
1251 .into_iter()
1252 .map(|candidate| (candidate.score, candidate.path))
1253 .collect::<Vec<_>>()
1254 };
1255
1256 let expected = search();
1257 for _ in 0..20 {
1258 assert_eq!(search(), expected);
1259 }
1260 assert!(
1261 expected.windows(2).all(|window| {
1262 window[0].0 > window[1].0 || (window[0].0 == window[1].0 && window[0].1 <= window[1].1)
1263 })
1264 );
1265 }
1266
1267 fn bounded_paths(workspace: &std::path::Path) -> Vec<String> {
1268 run_bounded_no_follow(FileSearchConfig {
1269 pattern_text: "widget".to_string(),
1270 limit: NonZero::new(2).expect("non-zero limit"),
1271 search_directory: workspace.to_path_buf(),
1272 exclude: Vec::new(),
1273 threads: NonZero::new(4).expect("non-zero threads"),
1274 cancel_flag: Arc::new(AtomicBool::new(false)),
1275 compute_indices: false,
1276 respect_gitignore: true,
1277 })
1278 .expect("bounded path search")
1279 .matches
1280 .into_iter()
1281 .map(|candidate| candidate.path)
1282 .collect()
1283 }
1284
1285 #[test]
1286 fn bounded_path_selection_is_stable_across_repeated_walks() {
1287 let workspace = TempDir::new().expect("workspace");
1288 for directory in ["z", "a", "m", "b", "y"] {
1289 let directory = workspace.path().join(directory);
1290 std::fs::create_dir(&directory).expect("fixture directory");
1291 std::fs::write(directory.join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1292 }
1293
1294 let expected = bounded_paths(workspace.path());
1295 assert_eq!(expected.len(), 2);
1296 for _ in 0..20 {
1297 assert_eq!(bounded_paths(workspace.path()), expected);
1298 }
1299 }
1300
1301 #[test]
1302 fn bounded_path_selection_is_the_sorted_prefix_and_stops_early() {
1303 let workspace = TempDir::new().expect("workspace");
1304 for directory in ["z", "a", "m", "b", "y"] {
1305 let directory = workspace.path().join(directory);
1306 std::fs::create_dir(&directory).expect("fixture directory");
1307 std::fs::write(directory.join("widget.rs"), "fn widget() {}\n").expect("fixture source");
1308 }
1309 let mut visited = Vec::new();
1310
1311 let results = run_bounded_no_follow_with_visit(
1312 FileSearchConfig {
1313 pattern_text: "widget".to_string(),
1314 limit: NonZero::new(2).expect("non-zero limit"),
1315 search_directory: workspace.path().to_path_buf(),
1316 exclude: Vec::new(),
1317 threads: NonZero::new(4).expect("non-zero threads"),
1318 cancel_flag: Arc::new(AtomicBool::new(false)),
1319 compute_indices: false,
1320 respect_gitignore: true,
1321 },
1322 |path| visited.push(path.to_path_buf()),
1323 )
1324 .expect("bounded path search");
1325 let mut paths = results.matches.into_iter().map(|candidate| candidate.path).collect::<Vec<_>>();
1326 paths.sort();
1327
1328 assert_eq!(paths, vec!["a/widget.rs", "b/widget.rs"]);
1329 assert!(visited.len() < 11, "the bounded route must stop before traversing the complete fixture tree");
1330 assert_eq!(results.total_match_count, 3);
1331 }
1332}