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
51mod types;
52
53pub use types::{FileMatch, FileSearchConfig, FileSearchResults, MatchType};
54
55pub struct FileIndex {
60 files: Vec<StringId>,
61 directories: Vec<StringId>,
62 path_texts_by_id: Arc<Vec<Arc<str>>>,
66 interner: vtcode_commons::StringInterner,
68 last_built: std::time::Instant,
69}
70
71fn build_parallel_walker(
73 search_directory: &Path,
74 exclude: &[String],
75 threads: usize,
76 respect_gitignore: bool,
77 follow_links: bool,
78) -> anyhow::Result<ignore::WalkParallel> {
79 let mut walk_builder = ignore::WalkBuilder::new(search_directory);
80 vtcode_commons::walk::apply_defaults(&mut walk_builder);
81
82 walk_builder.threads(threads);
84 walk_builder.follow_links(follow_links);
85 walk_builder.require_git(false); if !respect_gitignore {
88 walk_builder
89 .git_ignore(false)
90 .git_global(false)
91 .git_exclude(false)
92 .ignore(false)
93 .parents(false);
94 }
95
96 if !exclude.is_empty() {
97 let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
98 for exclude_pattern in exclude {
99 let pattern = format!("!{exclude_pattern}");
100 override_builder.add(&pattern)?;
101 }
102 walk_builder.overrides(override_builder.build()?);
103 }
104
105 Ok(walk_builder.build_parallel())
106}
107
108impl FileIndex {
109 fn build_from_directory(
112 search_directory: &Path,
113 exclude: &[String],
114 respect_gitignore: bool,
115 threads: usize,
116 ) -> anyhow::Result<Self> {
117 let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore, true)?;
118
119 let worker_results = Arc::new(Mutex::new(Vec::new()));
121
122 walker.run(|| {
123 let search_dir = search_directory.to_path_buf();
124 let mut state = IndexWorkerGuard {
125 results: Arc::clone(&worker_results),
126 files: Vec::new(),
127 directories: Vec::new(),
128 };
129
130 Box::new(move |result| {
131 let entry = match result {
132 Ok(e) => e,
133 Err(_) => return ignore::WalkState::Continue,
134 };
135
136 if let Some(rel_path) = entry.path().strip_prefix(&search_dir).ok().and_then(|p| p.to_str())
138 && !rel_path.is_empty()
139 {
140 if entry.file_type().is_some_and(|file_type| file_type.is_dir()) {
141 state.directories.push(rel_path.to_string());
142 } else {
143 state.files.push(rel_path.to_string());
144 }
145 }
146
147 ignore::WalkState::Continue
148 })
149 });
150
151 let worker_results = Arc::try_unwrap(worker_results)
152 .map_err(|results| {
153 anyhow::anyhow!("file index worker results still referenced: {}", Arc::strong_count(&results))
154 })?
155 .into_inner();
156 let (files, directories) =
157 worker_results
158 .into_iter()
159 .fold((Vec::new(), Vec::new()), |(mut files, mut directories), result| {
160 files.extend(result.files);
161 directories.extend(result.directories);
162 (files, directories)
163 });
164
165 let total_path_bytes: usize =
168 files.iter().map(String::len).sum::<usize>() + directories.iter().map(String::len).sum::<usize>();
169 let mut interner =
170 vtcode_commons::StringInterner::with_capacity(total_path_bytes, files.len() + directories.len());
171 let mut path_texts_by_id = Vec::with_capacity(files.len() + directories.len());
172 let interned_files: Vec<StringId> = files
173 .iter()
174 .map(|path| intern_path(path, &mut interner, &mut path_texts_by_id))
175 .collect();
176 let interned_dirs: Vec<StringId> = directories
177 .iter()
178 .map(|path| intern_path(path, &mut interner, &mut path_texts_by_id))
179 .collect();
180
181 Ok(Self {
182 files: interned_files,
183 directories: interned_dirs,
184 path_texts_by_id: Arc::new(path_texts_by_id),
185 interner,
186 last_built: std::time::Instant::now(),
187 })
188 }
189
190 fn query(
193 &self,
194 pattern_text: &str,
195 limit: usize,
196 match_type_filter: Option<MatchType>,
197 ) -> Vec<(u32, StringId, MatchType)> {
198 let mut heaps = Vec::new();
203
204 if match_type_filter.is_none_or(|t| t == MatchType::File) {
205 heaps.push(score_paths_top_k(
206 &self.files,
207 self.path_texts_by_id.as_slice(),
208 limit,
209 pattern_text,
210 MatchType::File,
211 ));
212 }
213
214 if match_type_filter.is_none_or(|t| t == MatchType::Directory) {
215 heaps.push(score_paths_top_k(
216 &self.directories,
217 self.path_texts_by_id.as_slice(),
218 limit,
219 pattern_text,
220 MatchType::Directory,
221 ));
222 }
223
224 merge_top_k(heaps, limit)
225 .into_sorted_vec()
226 .into_iter()
227 .map(|Reverse(item)| item)
228 .collect()
229 }
230}
231
232fn intern_path(
233 path: &str,
234 interner: &mut vtcode_commons::StringInterner,
235 path_texts_by_id: &mut Vec<Arc<str>>,
236) -> StringId {
237 let path_id = interner.intern(path);
238 let path_index = path_id.as_u32() as usize;
239 if path_index == path_texts_by_id.len() {
240 path_texts_by_id.push(Arc::from(path));
241 }
242 path_id
243}
244
245fn score_paths_top_k(
253 paths: &[StringId],
254 path_texts_by_id: &[Arc<str>],
255 limit: usize,
256 pattern_text: &str,
257 match_type: MatchType,
258) -> BinaryHeap<Reverse<(u32, StringId, MatchType)>> {
259 const CHUNK: usize = 1024;
260
261 if paths.len() <= CHUNK {
264 let mut list = BestMatchesList::new(limit, pattern_text);
265 for &path_id in paths {
266 if let Some(path) = path_texts_by_id.get(path_id.as_u32() as usize) {
267 list.record_match(path_id, path, match_type);
268 }
269 }
270 return list.matches;
271 }
272
273 let heaps: Vec<_> = paths
274 .par_chunks(CHUNK)
275 .map_init(
276 || BestMatchesList::new(limit, pattern_text),
277 |list, chunk| {
278 for &path_id in chunk {
279 if let Some(path) = path_texts_by_id.get(path_id.as_u32() as usize) {
280 list.record_match(path_id, path, match_type);
281 }
282 }
283 std::mem::take(&mut list.matches)
284 },
285 )
286 .collect();
287
288 merge_top_k(heaps, limit)
289}
290
291fn merge_top_k(
297 heaps: Vec<BinaryHeap<Reverse<(u32, StringId, MatchType)>>>,
298 limit: usize,
299) -> BinaryHeap<Reverse<(u32, StringId, MatchType)>> {
300 let mut merged = BinaryHeap::with_capacity(limit);
301 for heap in heaps {
302 for Reverse(item) in heap.into_vec() {
303 push_top_match(&mut merged, limit, item.0, item.1, item.2);
304 }
305 }
306 merged
307}
308
309pub struct FileIndexCache {
311 cache: Arc<RwLock<Option<Arc<FileIndex>>>>,
312 snapshot: Arc<ArcSwapOption<FileIndex>>,
316 build_gate: Arc<tokio::sync::Semaphore>,
319 search_directory: std::path::PathBuf,
320 exclude: Vec<String>,
321 respect_gitignore: bool,
322 threads: usize,
323}
324
325impl FileIndexCache {
326 pub fn new(
327 search_directory: std::path::PathBuf,
328 exclude: impl IntoIterator<Item = String>,
329 respect_gitignore: bool,
330 threads: usize,
331 ) -> Self {
332 Self {
333 cache: Arc::new(RwLock::new(None)),
334 snapshot: Arc::new(ArcSwapOption::empty()),
335 build_gate: Arc::new(tokio::sync::Semaphore::new(1)),
336 search_directory,
337 exclude: exclude.into_iter().collect(),
338 respect_gitignore,
339 threads,
340 }
341 }
342
343 pub async fn get_or_build(&self) -> anyhow::Result<Arc<FileIndex>> {
345 {
347 let guard = self.cache.read().await;
348 if let Some(index) = guard.as_ref() {
349 if index.last_built.elapsed() < std::time::Duration::from_secs(300) {
351 return Ok(Arc::clone(index));
352 }
353 }
354 }
355
356 let _build_permit = self.build_gate.acquire().await?;
359 {
360 let guard = self.cache.read().await;
361 if let Some(index) = guard.as_ref()
362 && index.last_built.elapsed() < std::time::Duration::from_secs(300)
363 {
364 return Ok(Arc::clone(index));
365 }
366 }
367
368 let search_directory = self.search_directory.clone();
372 let exclude = self.exclude.clone();
373 let respect_gitignore = self.respect_gitignore;
374 let threads = self.threads;
375 let index = Arc::new(
376 tokio::task::spawn_blocking(move || {
377 FileIndex::build_from_directory(&search_directory, &exclude, respect_gitignore, threads)
378 })
379 .await??,
380 );
381
382 {
384 let mut guard = self.cache.write().await;
385 *guard = Some(Arc::clone(&index));
386 self.snapshot.store(Some(Arc::clone(&index)));
387 }
388 Ok(index)
389 }
390
391 pub fn refresh_background(&self) -> Option<Arc<FileIndex>> {
397 let runtime = match tokio::runtime::Handle::try_current() {
398 Ok(runtime) => runtime,
399 Err(error) => {
400 tracing::debug!(%error, "cannot refresh file index without a Tokio runtime");
401 return self.snapshot.load_full();
402 }
403 };
404
405 let search_directory = self.search_directory.clone();
407 let exclude = self.exclude.clone();
408 let respect_gitignore = self.respect_gitignore;
409 let threads = self.threads;
410 let cache = self.cache.clone();
411 let snapshot = Arc::clone(&self.snapshot);
412 let build_gate = Arc::clone(&self.build_gate);
413
414 runtime.spawn(async move {
415 let _build_permit = match build_gate.acquire_owned().await {
416 Ok(permit) => permit,
417 Err(error) => {
418 tracing::error!(%error, "file index build gate closed");
419 return;
420 }
421 };
422
423 match tokio::task::spawn_blocking(move || {
424 FileIndex::build_from_directory(&search_directory, &exclude, respect_gitignore, threads)
425 })
426 .await
427 {
428 Ok(Ok(new_index)) => {
429 let new_index = Arc::new(new_index);
430 let mut guard = cache.write().await;
431 *guard = Some(Arc::clone(&new_index));
432 snapshot.store(Some(new_index));
433 }
434 Ok(Err(error)) => {
435 tracing::error!(%error, "failed to rebuild file index");
436 }
437 Err(error) => {
438 tracing::error!(%error, "file index rebuild task failed");
439 }
440 }
441 });
442
443 self.snapshot.load_full()
444 }
445
446 pub fn update_file(&self, path: &str, is_added: bool) {
449 let mut guard = self.cache.blocking_write();
450 let Some(existing) = guard.take() else { return };
451
452 let mut index = Arc::try_unwrap(existing).unwrap_or_else(|arc| (*arc).clone());
453 if is_added {
454 let path_id = intern_path(path, &mut index.interner, Arc::make_mut(&mut index.path_texts_by_id));
455 let is_directory = self.search_directory.join(path).is_dir();
456 if is_directory {
457 index.files.retain(|&existing| existing != path_id);
458 if !index.directories.contains(&path_id) {
459 index.directories.push(path_id);
460 }
461 } else {
462 index.directories.retain(|&existing| existing != path_id);
463 if !index.files.contains(&path_id) {
464 index.files.push(path_id);
465 }
466 }
467 } else {
468 let Some(path_id) = index.files.iter().chain(index.directories.iter()).copied().find(|&path_id| {
469 index
470 .path_texts_by_id
471 .get(path_id.as_u32() as usize)
472 .is_some_and(|value| value.as_ref() == path)
473 }) else {
474 let index = Arc::new(index);
475 *guard = Some(Arc::clone(&index));
476 self.snapshot.store(Some(index));
477 return;
478 };
479 index.files.retain(|&existing| existing != path_id);
480 index.directories.retain(|&existing| existing != path_id);
481 }
482 index.last_built = std::time::Instant::now();
483 let index = Arc::new(index);
484 *guard = Some(Arc::clone(&index));
485 self.snapshot.store(Some(index));
486 }
487
488 pub async fn index_age(&self) -> Option<std::time::Duration> {
490 let guard = self.cache.read().await;
491 guard.as_ref().map(|idx| idx.last_built.elapsed())
492 }
493}
494
495impl Clone for FileIndex {
497 fn clone(&self) -> Self {
498 Self {
499 files: self.files.clone(),
500 directories: self.directories.clone(),
501 path_texts_by_id: Arc::clone(&self.path_texts_by_id),
502 interner: self.interner.clone(),
503 last_built: self.last_built,
504 }
505 }
506}
507
508pub use vtcode_commons::paths::file_name_from_path;
509
510struct BestMatchesList {
515 matches: BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
516 limit: usize,
517 matcher: nucleo_matcher::Matcher,
518 haystack_buf: Vec<char>,
519 pattern: PatternStorage,
521 owned_matches: BinaryHeap<Reverse<OwnedMatchCandidate>>,
522}
523
524#[derive(Debug, Eq, PartialEq)]
525struct OwnedMatchCandidate {
526 score: u32,
527 path: String,
528 match_type: MatchType,
529}
530
531impl Ord for OwnedMatchCandidate {
532 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
533 self.score
534 .cmp(&other.score)
535 .then_with(|| other.path.cmp(&self.path))
536 .then_with(|| other.match_type.cmp(&self.match_type))
537 }
538}
539
540impl PartialOrd for OwnedMatchCandidate {
541 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
542 Some(self.cmp(other))
543 }
544}
545
546struct SearchAggregation {
547 matches: BinaryHeap<Reverse<OwnedMatchCandidate>>,
548 total_match_count: usize,
549}
550
551struct IndexWorkerResult {
552 files: Vec<String>,
553 directories: Vec<String>,
554}
555
556struct IndexWorkerGuard {
557 results: Arc<Mutex<Vec<IndexWorkerResult>>>,
558 files: Vec<String>,
559 directories: Vec<String>,
560}
561
562impl Drop for IndexWorkerGuard {
563 fn drop(&mut self) {
564 self.results.lock().push(IndexWorkerResult {
565 files: std::mem::take(&mut self.files),
566 directories: std::mem::take(&mut self.directories),
567 });
568 }
569}
570
571struct SearchWorkerGuard {
572 results: Arc<Mutex<SearchAggregation>>,
573 best_list: BestMatchesList,
574 total_match_count: usize,
575 limit: usize,
576}
577
578impl Drop for SearchWorkerGuard {
579 fn drop(&mut self) {
580 let worker_matches = std::mem::take(&mut self.best_list.owned_matches);
581 let mut results = self.results.lock();
582 results.total_match_count += self.total_match_count;
583 for Reverse(candidate) in worker_matches {
584 push_owned_match(&mut results.matches, self.limit, candidate);
585 }
586 }
587}
588
589enum PatternStorage {
591 Ascii(Vec<u8>),
593 Unicode(Vec<char>),
595}
596
597impl BestMatchesList {
598 fn new(limit: usize, pattern_text: &str) -> Self {
599 let pattern = if pattern_text.is_ascii() {
603 PatternStorage::Ascii(pattern_text.to_ascii_lowercase().into_bytes())
604 } else {
605 PatternStorage::Unicode(pattern_text.to_lowercase().chars().collect())
606 };
607
608 Self {
609 matches: BinaryHeap::new(),
610 limit,
611 matcher: nucleo_matcher::Matcher::new(nucleo_matcher::Config::DEFAULT),
612 haystack_buf: Vec::with_capacity(256),
613 pattern,
614 owned_matches: BinaryHeap::new(),
615 }
616 }
617
618 fn score_path(&mut self, path: &str) -> Option<u32> {
621 let haystack = nucleo_matcher::Utf32Str::new(path, &mut self.haystack_buf);
622 let needle = match &self.pattern {
623 PatternStorage::Ascii(bytes) => nucleo_matcher::Utf32Str::Ascii(bytes),
624 PatternStorage::Unicode(chars) => nucleo_matcher::Utf32Str::Unicode(chars),
625 };
626 self.matcher.fuzzy_match(haystack, needle).map(|score| score as u32)
627 }
628
629 fn record_match(&mut self, path_id: StringId, path: &str, match_type: MatchType) -> bool {
631 let Some(score) = self.score_path(path) else {
632 return false;
633 };
634 push_top_match(&mut self.matches, self.limit, score, path_id, match_type);
635 true
636 }
637
638 fn record_scored_match(&mut self, path_id: StringId, score: u32, match_type: MatchType) {
639 push_top_match(&mut self.matches, self.limit, score, path_id, match_type);
640 }
641
642 fn record_owned_match(&mut self, path: &str, score: u32, match_type: MatchType) {
643 if self.owned_matches.len() == self.limit {
644 let Some(Reverse(worst)) = self.owned_matches.peek() else {
645 return;
646 };
647 if !owned_match_is_better(score, path, match_type, worst) {
648 return;
649 }
650 }
651
652 push_owned_match(
653 &mut self.owned_matches,
654 self.limit,
655 OwnedMatchCandidate { score, path: path.to_owned(), match_type },
656 );
657 }
658}
659
660fn push_owned_match(
661 matches: &mut BinaryHeap<Reverse<OwnedMatchCandidate>>,
662 limit: usize,
663 candidate: OwnedMatchCandidate,
664) {
665 if matches.len() == limit {
666 let Some(Reverse(worst)) = matches.peek() else {
667 return;
668 };
669 if !owned_match_is_better(candidate.score, candidate.path.as_str(), candidate.match_type, worst) {
670 return;
671 }
672 matches.pop();
673 }
674
675 matches.push(Reverse(candidate));
676}
677
678fn owned_match_is_better(score: u32, path: &str, match_type: MatchType, worst: &OwnedMatchCandidate) -> bool {
679 score
680 .cmp(&worst.score)
681 .then_with(|| worst.path.as_str().cmp(path))
682 .then_with(|| worst.match_type.cmp(&match_type))
683 .is_gt()
684}
685
686fn push_top_match(
687 matches: &mut BinaryHeap<Reverse<(u32, StringId, MatchType)>>,
688 limit: usize,
689 score: u32,
690 path: StringId,
691 match_type: MatchType,
692) -> bool {
693 let candidate = (score, path, match_type);
694 if matches.len() < limit {
695 matches.push(Reverse(candidate));
696 return true;
697 }
698
699 let Some(minimum) = matches.peek().map(|entry| &entry.0) else {
700 return false;
701 };
702
703 if &candidate <= minimum {
704 return false;
705 }
706
707 matches.pop();
708 matches.push(Reverse(candidate));
709 true
710}
711
712pub async fn run_with_index(
726 config: FileSearchConfig,
727 index_cache: &FileIndexCache,
728) -> anyhow::Result<FileSearchResults> {
729 let limit = config.limit.get();
730 let cancel_flag = &config.cancel_flag;
731 let compute_indices = config.compute_indices;
732
733 let index = index_cache.get_or_build().await?;
735
736 if cancel_flag.load(Ordering::Relaxed) {
738 return Ok(FileSearchResults { matches: Vec::new(), total_match_count: 0 });
739 }
740
741 let index_for_results = index.clone();
744 let matched_paths = tokio::task::spawn_blocking({
745 let pattern_text = config.pattern_text.clone();
746 move || Ok::<_, anyhow::Error>(index.query(&pattern_text, limit, None))
747 })
748 .await??;
749
750 let total_match_count = matched_paths.len();
751
752 let matches = matched_paths
754 .into_iter()
755 .filter_map(|(score, path_id, match_type)| {
756 let path = index_for_results.path_texts_by_id.get(path_id.as_u32() as usize)?.to_string();
757 Some(FileMatch {
758 score,
759 path,
760 match_type,
761 indices: if compute_indices { Some(Vec::new()) } else { None },
762 })
763 })
764 .collect();
765
766 Ok(FileSearchResults { matches, total_match_count })
767}
768
769pub fn run(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
779 run_with_policy(config, true, false)
780}
781
782pub fn run_bounded_no_follow(config: FileSearchConfig) -> anyhow::Result<FileSearchResults> {
788 run_bounded_no_follow_with_visit(config, |_| {})
789}
790
791fn run_bounded_no_follow_with_visit(
792 config: FileSearchConfig,
793 mut visit: impl FnMut(&Path),
794) -> anyhow::Result<FileSearchResults> {
795 let limit = config.limit.get();
796 let search_directory = &config.search_directory;
797 let mut walk_builder = ignore::WalkBuilder::new(search_directory);
798 vtcode_commons::walk::apply_defaults(&mut walk_builder);
799 walk_builder
800 .follow_links(false)
801 .require_git(false)
802 .sort_by_file_path(|left, right| left.cmp(right));
803
804 if !config.respect_gitignore {
805 walk_builder
806 .git_ignore(false)
807 .git_global(false)
808 .git_exclude(false)
809 .ignore(false)
810 .parents(false);
811 }
812
813 if !config.exclude.is_empty() {
814 let mut override_builder = ignore::overrides::OverrideBuilder::new(search_directory);
815 for exclude_pattern in &config.exclude {
816 override_builder.add(&format!("!{exclude_pattern}"))?;
817 }
818 walk_builder.overrides(override_builder.build()?);
819 }
820
821 let interner = Arc::new(Mutex::new(vtcode_commons::StringInterner::new()));
822 let mut matches = BestMatchesList::new(limit, &config.pattern_text);
823 let mut matching_count = 0usize;
824 for result in walk_builder.build() {
825 if config.cancel_flag.load(Ordering::Relaxed) {
826 break;
827 }
828 let entry = match result {
829 Ok(entry) => entry,
830 Err(_) => continue,
831 };
832 visit(entry.path());
833 if !entry.file_type().is_some_and(|file_type| file_type.is_file()) {
834 continue;
835 }
836 let Some(relative_path) = entry
837 .path()
838 .strip_prefix(search_directory)
839 .ok()
840 .and_then(|path| path.to_str())
841 .filter(|path| !path.is_empty())
842 else {
843 continue;
844 };
845 let Some(score) = matches.score_path(relative_path) else {
846 continue;
847 };
848 let path_id = interner.lock().intern(relative_path);
849 matches.record_scored_match(path_id, score, MatchType::File);
850 matching_count += 1;
851 if matching_count >= limit {
852 break;
853 }
854 }
855
856 let interner_guard = interner.lock();
857 let matches = matches
858 .matches
859 .into_sorted_vec()
860 .into_iter()
861 .filter_map(|Reverse((score, path_id, match_type))| {
862 let path = interner_guard.get(path_id)?.to_string();
863 Some(FileMatch {
864 score,
865 path,
866 match_type,
867 indices: config.compute_indices.then(Vec::new),
868 })
869 })
870 .collect();
871
872 Ok(FileSearchResults {
873 matches,
874 total_match_count: matching_count + usize::from(matching_count >= limit),
877 })
878}
879
880fn run_with_policy(
881 config: FileSearchConfig,
882 follow_links: bool,
883 files_only: bool,
884) -> anyhow::Result<FileSearchResults> {
885 let limit = config.limit.get();
886 let search_directory = &config.search_directory;
887 let exclude = &config.exclude;
888 let threads = config.threads.get();
889 let cancel_flag = &config.cancel_flag;
890 let compute_indices = config.compute_indices;
891 let respect_gitignore = config.respect_gitignore;
892
893 let walker = build_parallel_walker(search_directory, exclude, threads, respect_gitignore, follow_links)?;
894
895 let worker_results = Arc::new(Mutex::new(SearchAggregation { matches: BinaryHeap::new(), total_match_count: 0 }));
896 walker.run(|| {
897 let mut state = SearchWorkerGuard {
898 results: Arc::clone(&worker_results),
899 best_list: BestMatchesList::new(limit, &config.pattern_text),
900 total_match_count: 0,
901 limit,
902 };
903
904 Box::new(move |result| {
905 if cancel_flag.load(Ordering::Relaxed) {
907 return ignore::WalkState::Quit;
908 }
909
910 let entry = match result {
911 Ok(e) => e,
912 Err(_) => return ignore::WalkState::Continue,
913 };
914
915 let relative_path = entry.path().strip_prefix(search_directory).ok().and_then(|p| p.to_str());
917
918 let path_to_match = match relative_path {
919 Some(p) if !p.is_empty() => p,
920 _ => return ignore::WalkState::Continue, };
922
923 let Some(file_type) = entry.file_type() else {
924 return ignore::WalkState::Continue;
925 };
926 let match_type = if file_type.is_dir() {
927 MatchType::Directory
928 } else {
929 MatchType::File
930 };
931
932 if files_only && match_type == MatchType::Directory {
933 return ignore::WalkState::Continue;
934 }
935
936 let Some(score) = state.best_list.score_path(path_to_match) else {
938 return ignore::WalkState::Continue;
939 };
940 state.best_list.record_owned_match(path_to_match, score, match_type);
941 state.total_match_count += 1;
942
943 ignore::WalkState::Continue
944 })
945 });
946
947 let results = Arc::try_unwrap(worker_results)
948 .map_err(|results| {
949 anyhow::anyhow!("file search worker results still referenced: {}", Arc::strong_count(&results))
950 })?
951 .into_inner();
952 let total_match_count = results.total_match_count;
953 let mut candidates = results
954 .matches
955 .into_iter()
956 .map(|Reverse(candidate)| candidate)
957 .collect::<Vec<_>>();
958 candidates.sort_by(|left, right| {
959 right
960 .score
961 .cmp(&left.score)
962 .then_with(|| left.path.cmp(&right.path))
963 .then_with(|| left.match_type.cmp(&right.match_type))
964 });
965 candidates.truncate(limit);
966
967 let matches = candidates
969 .into_iter()
970 .map(|candidate| FileMatch {
971 score: candidate.score,
972 path: candidate.path,
973 match_type: candidate.match_type,
974 indices: if compute_indices { Some(Vec::new()) } else { None },
975 })
976 .collect();
977
978 Ok(FileSearchResults { matches, total_match_count })
979}
980
981#[cfg(test)]
982mod tests;