1use crate::FFFStringStorage;
34use crate::background_watcher::{BackgroundWatcher, is_git_file};
35use crate::bigram_filter::{BigramFilter, BigramOverlay};
36use crate::constants::{MAX_OVERFLOW_FILES, PATH_BUF_SIZE};
37use crate::error::Error;
38use crate::frecency::FrecencyTracker;
39use crate::git::GitStatusCache;
40use crate::grep::{GrepResult, GrepSearchOptions, grep_search, multi_grep_search};
41use crate::ignore::non_git_repo_overrides;
42use crate::query_tracker::QueryTracker;
43use crate::scan::{ScanConfig, ScanJob, ScanSignals};
44use crate::score::fuzzy_match_and_score_files;
45use crate::shared::{SharedFilePicker, SharedFrecency};
46use crate::simd_path::ArenaPtr;
47use crate::stable_vec::StableVec;
48use crate::types::{
49 ContentCacheBudget, DirItem, DirSearchResult, FileItem, MixedItemRef, MixedSearchResult,
50 PaginationArgs, Score, ScoringContext, SearchResult,
51};
52use fff_query_parser::FFFQuery;
53use git2::{Repository, Status};
54use rayon::prelude::*;
55use std::fmt::Debug;
56use std::ops::ControlFlow;
57use std::path::{Path, PathBuf};
58use std::sync::{
59 Arc,
60 atomic::{AtomicBool, AtomicUsize, Ordering},
61};
62use std::thread::JoinHandle;
63use std::time::SystemTime;
64use tracing::{Level, debug, error, info, warn};
65
66use crate::parallelism::{BACKGROUND_THREAD_POOL, SEARCH_THREAD_POOL};
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum FFFMode {
70 #[default]
71 Neovim,
72 Ai,
73}
74
75impl FFFMode {
76 pub fn is_ai(self) -> bool {
77 self == FFFMode::Ai
78 }
79}
80
81#[derive(Debug, Clone, Copy, Default)]
86pub struct FuzzySearchOptions<'a> {
87 pub max_threads: usize,
88 pub current_file: Option<&'a str>,
89 pub project_path: Option<&'a Path>,
90 pub combo_boost_score_multiplier: i32,
91 pub min_combo_count: u32,
92 pub pagination: PaginationArgs,
93}
94
95#[derive(Debug, Clone)]
96pub(crate) struct FileSync {
97 pub(crate) git_workdir: Option<PathBuf>,
98 files: StableVec<FileItem>,
104 indexable_count: usize,
105 base_count: usize,
106 pub(crate) live_count: usize,
108 dirs: StableVec<DirItem>,
114 overflow_builder: Option<crate::simd_path::ChunkedPathStoreBuilder>,
117 bigram_index: Option<Arc<BigramFilter>>,
118 bigram_overlay: Option<Arc<parking_lot::RwLock<BigramOverlay>>>,
119 chunked_paths: Option<Arc<crate::simd_path::ChunkedPathStore>>,
122}
123
124impl FileSync {
125 fn new() -> Self {
126 Self {
127 files: StableVec::from_vec_with_reserve(Vec::new(), MAX_OVERFLOW_FILES),
128 indexable_count: 0,
129 base_count: 0,
130 live_count: 0,
131 dirs: StableVec::from_vec_with_reserve(Vec::new(), 0),
132 overflow_builder: None,
133 git_workdir: None,
134 bigram_index: None,
135 bigram_overlay: None,
136 chunked_paths: None,
137 }
138 }
139
140 #[inline]
141 fn arena_base_ptr(&self) -> ArenaPtr {
142 self.chunked_paths
143 .as_ref()
144 .map(|s| s.as_arena_ptr())
145 .unwrap_or(ArenaPtr::null())
146 }
147
148 #[inline]
149 fn arena_overflow_ptr(&self) -> ArenaPtr {
150 self.overflow_builder
151 .as_ref()
152 .map(|b| b.as_arena_ptr())
153 .unwrap_or(ArenaPtr::null())
154 }
155
156 #[inline]
157 fn arena_for_file(&self, file: &FileItem) -> ArenaPtr {
158 if file.is_overflow() {
159 self.arena_overflow_ptr()
160 } else {
161 self.arena_base_ptr()
162 }
163 }
164
165 #[inline]
166 fn files(&self) -> &[FileItem] {
167 &self.files
168 }
169
170 #[inline]
171 fn overflow_files(&self) -> &[FileItem] {
172 &self.files[self.base_count..]
173 }
174
175 #[inline]
176 fn get_file_mut(&mut self, index: usize) -> Option<(ArenaPtr, &mut FileItem)> {
177 Some((
178 if index < self.base_count {
179 self.arena_base_ptr()
180 } else {
181 self.arena_overflow_ptr()
182 },
183 self.files.get_mut(index)?,
184 ))
185 }
186
187 #[inline]
188 fn find_file_index(&self, path: &Path, base_path: &Path) -> Option<usize> {
189 let arena = self.arena_base_ptr();
190
191 let rel_path_owned: String = match path.strip_prefix(base_path) {
196 Ok(r) => r.to_string_lossy().into_owned(),
197 Err(_) => {
198 #[cfg(windows)]
199 {
200 canonical_relative_path(path, base_path)?
201 }
202 #[cfg(not(windows))]
203 {
204 return None;
205 }
206 }
207 };
208 let rel_path: &str = &rel_path_owned;
209
210 let parent_end = rel_path
212 .rfind(std::path::is_separator)
213 .map(|i| i + 1)
214 .unwrap_or(0);
215 let dir_rel = &rel_path[..parent_end];
216 let filename = &rel_path[parent_end..];
217
218 let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
221 let dir_idx = self
222 .dirs
223 .binary_search_by(|d| d.read_relative_path(arena, &mut dir_buf).cmp(dir_rel))
224 .ok();
225
226 if let Some(dir_idx) = dir_idx {
230 let dir_idx = dir_idx as u32;
231 let cmp_key = |f: &FileItem| {
232 f.parent_dir_index.cmp(&dir_idx).then_with(|| {
233 let fname = f.file_name(arena);
234 fname.as_str().cmp(filename)
235 })
236 };
237
238 if self.indexable_count > 0
239 && let Ok(pos) = self.files[..self.indexable_count].binary_search_by(cmp_key)
240 {
241 return Some(pos);
242 }
243
244 if self.indexable_count < self.base_count
245 && let Ok(rel_pos) =
246 self.files[self.indexable_count..self.base_count].binary_search_by(cmp_key)
247 {
248 return Some(self.indexable_count + rel_pos);
249 }
250 }
251
252 if self.base_count < self.files.len() {
254 let overflow_arena = self.arena_overflow_ptr();
255 if let Some(pos) = self.files[self.base_count..]
256 .iter()
257 .position(|f| f.relative_path_eq(overflow_arena, rel_path))
258 {
259 return Some(self.base_count + pos);
260 }
261 }
262
263 None
264 }
265
266 fn tombstone_files_with_arena<F>(&mut self, mut predicate: F) -> usize
270 where
271 F: FnMut(&FileItem, ArenaPtr) -> bool,
272 {
273 let base_arena = self.arena_base_ptr();
274 let overflow_arena = self.arena_overflow_ptr();
275 let base_count = self.base_count;
276
277 let mut tombstoned = 0usize;
278 for (idx, file) in self.files.iter_mut().enumerate() {
279 if file.is_deleted() {
280 continue;
281 }
282 let arena = if idx < base_count {
283 base_arena
284 } else {
285 overflow_arena
286 };
287 if predicate(file, arena) {
288 file.set_deleted(true);
289 tombstoned += 1;
290 }
291 }
292 self.live_count -= tombstoned;
293 tombstoned
294 }
295}
296
297impl FileItem {
298 pub fn new(path: PathBuf, base_path: &Path, git_status: Option<Status>) -> (Self, String) {
299 let metadata = std::fs::metadata(&path).ok();
300 Self::new_with_metadata(path, base_path, git_status, metadata.as_ref())
301 }
302
303 fn new_with_metadata(
307 path: PathBuf,
308 base_path: &Path,
309 git_status: Option<Status>,
310 metadata: Option<&std::fs::Metadata>,
311 ) -> (Self, String) {
312 let path_buf = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
313 let relative_path = path_buf.to_string_lossy().into_owned();
314
315 let (size, modified) = match metadata {
316 Some(metadata) => {
317 let size = metadata.len();
318 let modified = metadata
319 .modified()
320 .ok()
321 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
322 .map_or(0, |d| d.as_secs());
323
324 (size, modified)
325 }
326 None => (0, 0),
327 };
328
329 let is_binary = is_known_binary_extension(&path);
330
331 let filename_start = relative_path
332 .rfind(std::path::is_separator)
333 .map(|i| i + 1)
334 .unwrap_or(0) as u16;
335
336 let item = Self::new_raw(filename_start, size, modified, git_status, is_binary);
337 (item, relative_path)
338 }
339
340 pub fn new_from_walk(
346 path: &Path,
347 base_path: &Path,
348 git_status: Option<Status>,
349 metadata: Option<&std::fs::Metadata>,
350 ) -> (Self, String) {
351 let (size, modified) = match metadata {
352 Some(metadata) => {
353 let size = metadata.len();
354 let modified = metadata
355 .modified()
356 .ok()
357 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
358 .map_or(0, |d| d.as_secs());
359 (size, modified)
360 }
361 None => (0, 0),
362 };
363
364 let is_binary = is_known_binary_extension(path);
365
366 let rel = pathdiff::diff_paths(path, base_path).unwrap_or_else(|| path.to_path_buf());
367 let rel_str = rel.to_string_lossy().into_owned();
368 let fname_offset = rel_str
369 .rfind(std::path::is_separator)
370 .map(|i| i + 1)
371 .unwrap_or(0) as u16;
372
373 let item = Self::new_raw(fname_offset, size, modified, git_status, is_binary);
374 (item, rel_str)
375 }
376
377 pub(crate) fn update_frecency_scores(
378 &mut self,
379 tracker: &FrecencyTracker,
380 arena: ArenaPtr,
381 base_path: &Path,
382 mode: FFFMode,
383 ) -> Result<(), Error> {
384 let mut abs_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
385 let abs = self.write_absolute_path(arena, base_path, &mut abs_buf);
386 self.access_frecency_score = tracker.get_access_score(abs, mode) as i16;
387 self.modification_frecency_score =
388 tracker.get_modification_score(self.modified, self.git_status, mode) as i16;
389
390 Ok(())
391 }
392}
393
394pub struct FilePickerOptions {
396 pub base_path: String,
397 pub enable_mmap_cache: bool,
399 pub enable_content_indexing: bool,
401 pub mode: FFFMode,
403 pub cache_budget: Option<ContentCacheBudget>,
406 pub watch: bool,
408 pub follow_symlinks: bool,
410 pub enable_fs_root_scanning: bool,
413 pub enable_home_dir_scanning: bool,
416}
417
418impl Default for FilePickerOptions {
419 fn default() -> Self {
420 Self {
421 base_path: ".".into(),
422 enable_mmap_cache: false,
423 enable_content_indexing: false,
424 mode: FFFMode::default(),
425 cache_budget: None,
426 watch: true,
427 follow_symlinks: false,
428 enable_fs_root_scanning: false,
429 enable_home_dir_scanning: false,
430 }
431 }
432}
433
434pub struct FilePicker {
435 pub mode: FFFMode,
436 pub base_path: PathBuf,
437 sync_data: FileSync,
438 pub(crate) signals: ScanSignals,
439 pub(crate) background_watcher: Option<BackgroundWatcher>,
440 cache_budget: Arc<ContentCacheBudget>,
441 has_explicit_cache_budget: bool,
442 scanned_files_count: Arc<AtomicUsize>,
443 enable_mmap_cache: bool,
444 enable_content_indexing: bool,
445 watch: bool,
446 follow_symlinks: bool,
447 enable_fs_root_scanning: bool,
448 enable_home_dir_scanning: bool,
449 trace_span: tracing::Span,
450 trace_id: String,
451}
452
453impl std::fmt::Debug for FilePicker {
454 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455 f.debug_struct("FilePicker")
456 .field("base_path", &self.base_path)
457 .field("sync_data", &self.sync_data)
458 .field(
459 "is_scanning",
460 &self.signals.scanning.load(Ordering::Relaxed),
461 )
462 .field(
463 "scanned_files_count",
464 &self.scanned_files_count.load(Ordering::Relaxed),
465 )
466 .finish_non_exhaustive()
467 }
468}
469
470impl FFFStringStorage for &FilePicker {
471 #[inline]
472 fn arena_for(&self, file: &FileItem) -> crate::simd_path::ArenaPtr {
473 self.sync_data.arena_for_file(file)
474 }
475
476 #[inline]
477 fn base_arena(&self) -> crate::simd_path::ArenaPtr {
478 self.sync_data.arena_base_ptr()
479 }
480
481 #[inline]
482 fn overflow_arena(&self) -> crate::simd_path::ArenaPtr {
483 self.sync_data.arena_overflow_ptr()
484 }
485}
486
487impl FilePicker {
488 pub fn base_path(&self) -> &Path {
489 &self.base_path
490 }
491
492 pub fn has_mmap_cache(&self) -> bool {
493 self.enable_mmap_cache
494 }
495
496 pub fn has_content_indexing(&self) -> bool {
497 self.enable_content_indexing
498 }
499
500 pub fn has_watcher(&self) -> bool {
501 self.watch
502 }
503
504 pub fn follows_symlinks(&self) -> bool {
505 self.follow_symlinks
506 }
507
508 pub fn fs_root_scanning_enabled(&self) -> bool {
509 self.enable_fs_root_scanning
510 }
511
512 pub fn home_dir_scanning_enabled(&self) -> bool {
513 self.enable_home_dir_scanning
514 }
515
516 pub fn trace_id(&self) -> &str {
517 &self.trace_id
518 }
519
520 pub fn trace_span(&self) -> tracing::Span {
521 self.trace_span.clone()
522 }
523
524 pub fn mode(&self) -> FFFMode {
525 self.mode
526 }
527
528 pub fn cache_budget(&self) -> &ContentCacheBudget {
529 &self.cache_budget
530 }
531
532 pub fn bigram_index(&self) -> Option<&BigramFilter> {
533 self.sync_data.bigram_index.as_deref()
534 }
535
536 pub fn bigram_overlay(&self) -> Option<&parking_lot::RwLock<BigramOverlay>> {
537 self.sync_data.bigram_overlay.as_deref()
538 }
539
540 pub fn get_file_mut(&mut self, index: usize) -> Option<(ArenaPtr, &mut FileItem)> {
541 self.sync_data.get_file_mut(index)
542 }
543
544 pub fn git_root(&self) -> Option<&Path> {
547 self.sync_data.git_workdir.as_deref()
548 }
549
550 pub fn has_explicit_cache_budget(&self) -> bool {
551 self.has_explicit_cache_budget
552 }
553
554 pub fn set_cache_budget(&mut self, budget: ContentCacheBudget) {
555 self.cache_budget = Arc::new(budget);
556 }
557
558 pub fn get_files(&self) -> &[FileItem] {
562 self.sync_data.files()
563 }
564
565 #[inline]
567 pub fn live_file_count(&self) -> usize {
568 self.sync_data.live_count
569 }
570
571 pub fn get_overflow_files(&self) -> &[FileItem] {
572 self.sync_data.overflow_files()
573 }
574
575 pub fn get_dirs(&self) -> &[DirItem] {
577 &self.sync_data.dirs
578 }
579
580 pub fn arena_bytes(&self) -> (usize, usize, usize) {
583 let chunked = self
584 .sync_data
585 .chunked_paths
586 .as_ref()
587 .map_or(0, |s| s.heap_bytes());
588
589 (chunked, 0, 0)
590 }
591
592 #[tracing::instrument(level = "debug", skip_all)]
593 pub(crate) fn for_each_dir(&self, mut f: impl FnMut(&Path) -> ControlFlow<()>) {
594 let dir_table = &self.sync_data.dirs;
595 let base = self.base_path.as_path();
596
597 if !dir_table.is_empty() {
598 let arena = self.arena_base_ptr();
599 let mut path_buf = PathBuf::with_capacity(crate::simd_path::PATH_BUF_SIZE);
600 let mut prev_relative_path = String::new();
601
602 let mut scratch_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
603 for dir_item in dir_table.iter() {
604 let full_relative_path = dir_item.read_relative_path(arena, &mut scratch_buf);
605 let relative_path = full_relative_path.trim_end_matches(std::path::is_separator);
606
607 if relative_path.is_empty() {
608 prev_relative_path.clear();
610 continue;
611 }
612
613 let mut i = common_dir_prefix_len(&prev_relative_path, relative_path);
614 if i < relative_path.len()
618 && std::path::is_separator(relative_path.as_bytes()[i] as char)
619 {
620 i += 1;
621 }
622
623 while i < relative_path.len() {
626 let next_sep = relative_path[i..]
627 .find(std::path::is_separator)
628 .map(|off| i + off)
629 .unwrap_or(relative_path.len());
630 let ancestor_rel = &relative_path[..next_sep];
631
632 path_buf.clear();
633 path_buf.push(base);
634 path_buf.push(ancestor_rel);
635
636 if matches!(f(path_buf.as_path()), ControlFlow::Break(())) {
638 return;
639 }
640
641 i = next_sep + 1;
642 }
643
644 prev_relative_path.clear();
645 prev_relative_path.push_str(relative_path);
646 }
647 return;
648 }
649
650 let files = self.sync_data.files();
653 let arena = self.arena_base_ptr();
654 let mut current = self.base_path.clone();
655 let mut path_buf = [0u8; PATH_BUF_SIZE];
656
657 for file in files {
658 let abs = file.write_absolute_path(arena, base, &mut path_buf);
659 let Some(parent) = abs.parent() else {
660 continue;
661 };
662 if parent == current.as_path() {
663 continue;
664 }
665
666 while current.as_path() != base && !parent.starts_with(¤t) {
667 current.pop();
668 }
669
670 let Ok(remainder) = parent.strip_prefix(¤t) else {
671 continue;
672 };
673 for component in remainder.components() {
674 current.push(component);
675 if matches!(f(current.as_path()), ControlFlow::Break(())) {
676 return;
677 }
678 }
679 }
680 }
681
682 pub fn new(options: FilePickerOptions) -> Result<Self, Error> {
686 let path = PathBuf::from(&options.base_path);
687 if !path.exists() {
688 error!("Base path does not exist: {}", options.base_path);
689 return Err(Error::InvalidPath(path));
690 }
691 if path.parent().is_none() && !options.enable_fs_root_scanning {
692 error!("Refusing to index filesystem root: {}", path.display());
693 return Err(Error::FilesystemRoot(path));
694 }
695 if !options.enable_home_dir_scanning
696 && Some(path.as_os_str()) == dirs::home_dir().as_ref().map(|p| p.as_os_str())
697 {
698 error!("Refusing to index home directory: {}", path.display());
699 return Err(Error::FilesystemRoot(path));
700 }
701
702 #[cfg(windows)]
706 let path = crate::path_utils::canonicalize(&path).unwrap_or(path);
707
708 let has_explicit_budget = options.cache_budget.is_some();
709 let initial_budget = options.cache_budget.unwrap_or_default();
710
711 let trace_id = crate::log::generate_trace_id();
712 let trace_span = crate::log::trace_span(&trace_id, "picker");
713
714 Ok(FilePicker {
715 background_watcher: None,
716 base_path: path,
717 cache_budget: Arc::new(initial_budget),
718 has_explicit_cache_budget: has_explicit_budget,
719 signals: crate::scan::ScanSignals::default(),
720 mode: options.mode,
721 scanned_files_count: Arc::new(AtomicUsize::new(0)),
722 sync_data: FileSync::new(),
723 enable_mmap_cache: options.enable_mmap_cache,
724 enable_content_indexing: options.enable_content_indexing,
725 watch: options.watch,
726 follow_symlinks: options.follow_symlinks,
727 enable_fs_root_scanning: options.enable_fs_root_scanning,
728 enable_home_dir_scanning: options.enable_home_dir_scanning,
729 trace_span,
730 trace_id,
731 })
732 }
733
734 pub fn new_with_shared_state(
737 shared_picker: SharedFilePicker,
738 shared_frecency: SharedFrecency,
739 options: FilePickerOptions,
740 ) -> Result<(), Error> {
741 let picker = Self::new(options)?;
742
743 info!(
744 "Spawning background threads: base_path={}, warmup={}, content_indexing={}, mode={:?}",
745 picker.base_path.display(),
746 picker.enable_mmap_cache,
747 picker.enable_content_indexing,
748 picker.mode,
749 );
750
751 let warmup = picker.enable_mmap_cache;
752 let content_indexing = picker.enable_content_indexing;
753 let watch = picker.watch;
754 let mode = picker.mode;
755 let follow_symlinks = picker.follow_symlinks;
756 let enable_fs_root_scanning = picker.enable_fs_root_scanning;
757 let enable_home_dir_scanning = picker.enable_home_dir_scanning;
758
759 let signals = picker.scan_signals();
760 let scanned_files_counter = picker.scanned_files_counter();
761 let path = picker.base_path.clone();
762 let trace_span = picker.trace_span.clone();
763
764 signals
771 .scanning
772 .store(true, std::sync::atomic::Ordering::Release);
773
774 {
775 let mut guard = shared_picker.write()?;
776 *guard = Some(picker);
777 }
779
780 ScanJob::new_initial(
781 shared_picker,
782 shared_frecency,
783 path,
784 mode,
785 signals,
786 scanned_files_counter,
787 trace_span,
788 ScanConfig {
789 warmup,
790 content_indexing,
791 watch,
792 auto_cache_budget: true,
793 install_watcher: true,
794 follow_symlinks,
795 enable_fs_root_scanning,
796 enable_home_dir_scanning,
797 },
798 )
799 .spawn();
800
801 Ok(())
802 }
803
804 pub fn collect_files(&mut self) -> Result<(), Error> {
813 self.signals.scanning.store(true, Ordering::Relaxed);
814 self.scanned_files_count.store(0, Ordering::Relaxed);
815
816 let git_workdir = FileSync::discover_git_workdir(&self.base_path);
817 let git_handle = git_workdir.clone().map(FileSync::spawn_git_status);
818
819 let empty_frecency = SharedFrecency::default();
820 let sync = FileSync::walk_filesystem(
821 &self.base_path,
822 git_workdir,
823 &self.scanned_files_count,
824 &empty_frecency,
825 self.mode,
826 self.follow_symlinks,
827 )?;
828
829 self.sync_data = sync;
830
831 if !self.has_explicit_cache_budget {
832 let file_count = self.sync_data.files().len();
833 self.cache_budget = Arc::new(ContentCacheBudget::new_for_repo(file_count));
834 } else {
835 self.cache_budget.reset();
836 }
837
838 if let Some(handle) = git_handle
839 && let Ok(Some(git_cache)) = handle.join()
840 {
841 let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
842
843 let arena = self.arena_base_ptr();
844 for file in self.sync_data.files.iter_mut() {
845 file.git_status = git_cache.lookup_status(file.write_absolute_path(
846 arena,
847 &self.base_path,
848 &mut path_buf,
849 ));
850 }
851 }
852
853 self.signals.scanning.store(false, Ordering::Relaxed);
854 Ok(())
855 }
856
857 pub fn spawn_background_watcher(
863 &mut self,
864 shared_picker: &SharedFilePicker,
865 shared_frecency: &SharedFrecency,
866 ) -> Result<(), Error> {
867 let git_workdir = self.sync_data.git_workdir.clone();
868 let watcher = BackgroundWatcher::new(
869 self.base_path.clone(),
870 git_workdir,
871 shared_picker.clone(),
872 shared_frecency.clone(),
873 self.mode,
874 self.enable_fs_root_scanning,
875 self.enable_home_dir_scanning,
876 self.trace_span.clone(),
877 )?;
878 self.background_watcher = Some(watcher);
879 self.signals.watcher_ready.store(true, Ordering::Release);
880 Ok(())
881 }
882
883 #[tracing::instrument(skip_all, name = "Fuzzy file search", fields(query = query.raw_query))]
889 pub fn fuzzy_search<'q>(
890 &self,
891 query: &'q FFFQuery<'q>,
892 query_tracker: Option<&QueryTracker>,
893 options: FuzzySearchOptions<'q>,
894 ) -> SearchResult<'_> {
895 let files = self.get_files();
896 let max_threads = if options.max_threads == 0 {
897 std::thread::available_parallelism()
898 .map(|n| n.get())
899 .unwrap_or(4)
900 } else {
901 options.max_threads
902 };
903
904 debug!(
905 raw_query = ?query.raw_query,
906 pagination = ?options.pagination,
907 ?max_threads,
908 current_file = ?options.current_file,
909 "Fuzzy search",
910 );
911
912 let total_files = self.live_file_count();
913 let location = query.location;
914
915 let effective_query = match &query.fuzzy_query {
917 fff_query_parser::FuzzyQuery::Text(t) => *t,
918 fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
919 _ => query.raw_query.trim(),
920 };
921
922 let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
924 let last_same_query_entry =
926 query_tracker
927 .zip(options.project_path)
928 .and_then(|(tracker, project_path)| {
929 tracker
930 .get_last_query_entry(
931 query.raw_query,
932 project_path,
933 options.min_combo_count,
934 )
935 .ok()
936 .flatten()
937 });
938
939 let context = ScoringContext {
940 query,
941 max_typos,
942 max_threads,
943 project_path: options.project_path,
944 current_file: options.current_file,
945 last_same_query_match: last_same_query_entry,
946 combo_boost_score_multiplier: options.combo_boost_score_multiplier,
947 min_combo_count: options.min_combo_count,
948 pagination: options.pagination,
949 };
950
951 let time = std::time::Instant::now();
952
953 let base_arena = self.sync_data.arena_base_ptr();
954 let overflow_arena = self.sync_data.arena_overflow_ptr();
955
956 let (items, scores, total_matched) = fuzzy_match_and_score_files(
957 files,
958 &context,
959 self.sync_data.base_count,
960 base_arena,
961 overflow_arena,
962 );
963
964 info!(
965 ?query,
966 completed_in = ?time.elapsed(),
967 total_matched,
968 returned_count = items.len(),
969 pagination = ?options.pagination,
970 "Fuzzy search completed",
971 );
972
973 SearchResult {
974 items,
975 scores,
976 total_matched,
977 total_files,
978 location,
979 }
980 }
981
982 pub fn fuzzy_search_directories<'q>(
986 &self,
987 query: &'q FFFQuery<'q>,
988 options: FuzzySearchOptions<'q>,
989 ) -> DirSearchResult<'_> {
990 let dirs = self.get_dirs();
991 let max_threads = if options.max_threads == 0 {
992 std::thread::available_parallelism()
993 .map(|n| n.get())
994 .unwrap_or(4)
995 } else {
996 options.max_threads
997 };
998
999 let total_dirs = dirs.len();
1000
1001 let effective_query = match &query.fuzzy_query {
1002 fff_query_parser::FuzzyQuery::Text(t) => *t,
1003 fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
1004 _ => query.raw_query.trim(),
1005 };
1006
1007 let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
1008
1009 let context = ScoringContext {
1010 query,
1011 max_typos,
1012 max_threads,
1013 project_path: options.project_path,
1014 current_file: options.current_file,
1015 last_same_query_match: None,
1016 combo_boost_score_multiplier: 0,
1017 min_combo_count: 0,
1018 pagination: options.pagination,
1019 };
1020
1021 let arena = self.sync_data.arena_base_ptr();
1022 let time = std::time::Instant::now();
1023
1024 let (items, scores, total_matched) =
1025 crate::score::fuzzy_match_and_score_dirs(dirs, &context, arena);
1026
1027 info!(
1028 ?query,
1029 completed_in = ?time.elapsed(),
1030 total_matched,
1031 returned_count = items.len(),
1032 "Directory search completed",
1033 );
1034
1035 DirSearchResult {
1036 items,
1037 scores,
1038 total_matched,
1039 total_dirs,
1040 }
1041 }
1042
1043 pub fn fuzzy_search_mixed<'q>(
1053 &self,
1054 query: &'q FFFQuery<'q>,
1055 query_tracker: Option<&QueryTracker>,
1056 options: FuzzySearchOptions<'q>,
1057 ) -> MixedSearchResult<'_> {
1058 let location = query.location;
1059 let page_offset = options.pagination.offset;
1060 let page_limit = if options.pagination.limit > 0 {
1061 options.pagination.limit
1062 } else {
1063 100
1064 };
1065
1066 let dirs_only =
1067 query.raw_query.ends_with(std::path::MAIN_SEPARATOR) || query.raw_query.ends_with('/');
1068
1069 let internal_limit = page_offset.saturating_add(page_limit).saturating_mul(2);
1071
1072 let dir_options = FuzzySearchOptions {
1073 pagination: PaginationArgs {
1074 offset: 0,
1075 limit: internal_limit,
1076 },
1077 ..options
1078 };
1079 let dir_results = self.fuzzy_search_directories(query, dir_options);
1080
1081 if dirs_only {
1082 let total_matched = dir_results.total_matched;
1083 let total_dirs = dir_results.total_dirs;
1084
1085 let mut merged: Vec<(MixedItemRef<'_>, Score)> =
1086 Vec::with_capacity(dir_results.items.len());
1087 for (dir, score) in dir_results.items.into_iter().zip(dir_results.scores) {
1088 merged.push((MixedItemRef::Dir(dir), score));
1089 }
1090
1091 if page_offset >= merged.len() {
1092 return MixedSearchResult {
1093 items: vec![],
1094 scores: vec![],
1095 total_matched,
1096 total_files: self.live_file_count(),
1097 total_dirs,
1098 location,
1099 };
1100 }
1101
1102 let end = (page_offset + page_limit).min(merged.len());
1103 let page = merged.drain(page_offset..end);
1104 let (items, scores): (Vec<_>, Vec<_>) = page.unzip();
1105
1106 return MixedSearchResult {
1107 items,
1108 scores,
1109 total_matched,
1110 total_files: self.live_file_count(),
1111 total_dirs,
1112 location,
1113 };
1114 }
1115
1116 let file_options = FuzzySearchOptions {
1117 pagination: PaginationArgs {
1118 offset: 0,
1119 limit: internal_limit,
1120 },
1121 ..options
1122 };
1123 let file_results = self.fuzzy_search(query, query_tracker, file_options);
1124
1125 let total_matched = file_results.total_matched + dir_results.total_matched;
1127 let total_files = file_results.total_files;
1128 let total_dirs = dir_results.total_dirs;
1129
1130 let mut merged: Vec<(MixedItemRef<'_>, Score)> =
1131 Vec::with_capacity(file_results.items.len() + dir_results.items.len());
1132
1133 for (file, score) in file_results.items.into_iter().zip(file_results.scores) {
1134 merged.push((MixedItemRef::File(file), score));
1135 }
1136 for (dir, score) in dir_results.items.into_iter().zip(dir_results.scores) {
1137 merged.push((MixedItemRef::Dir(dir), score));
1138 }
1139
1140 merged.sort_unstable_by_key(|b| std::cmp::Reverse(b.1.total));
1142
1143 if page_offset >= merged.len() {
1145 return MixedSearchResult {
1146 items: vec![],
1147 scores: vec![],
1148 total_matched,
1149 total_files,
1150 total_dirs,
1151 location,
1152 };
1153 }
1154
1155 let end = (page_offset + page_limit).min(merged.len());
1156 let page = merged.drain(page_offset..end);
1157 let (items, scores): (Vec<_>, Vec<_>) = page.unzip();
1158
1159 MixedSearchResult {
1160 items,
1161 scores,
1162 total_matched,
1163 total_files,
1164 total_dirs,
1165 location,
1166 }
1167 }
1168
1169 pub fn glob<'p>(
1177 &'p self,
1178 pattern: &'p str,
1179 options: FuzzySearchOptions<'p>,
1180 ) -> SearchResult<'p> {
1181 let query = FFFQuery {
1182 raw_query: pattern,
1183 constraints: vec![fff_query_parser::Constraint::Glob(pattern)],
1184 fuzzy_query: fff_query_parser::FuzzyQuery::Empty,
1185 location: None,
1186 };
1187
1188 self.fuzzy_search(&query, None, options)
1193 }
1194
1195 pub fn grep(&self, query: &FFFQuery<'_>, options: &GrepSearchOptions) -> GrepResult<'_> {
1200 let overlay_guard = self.sync_data.bigram_overlay.as_ref().map(|o| o.read());
1201 let arena = self.arena_base_ptr();
1202 let overflow_arena = self.sync_data.arena_overflow_ptr();
1203 let cancel = options
1204 .abort_signal
1205 .as_deref()
1206 .unwrap_or(&self.signals.cancelled);
1207
1208 SEARCH_THREAD_POOL.install(|| {
1209 grep_search(
1210 self.get_files(),
1211 query,
1212 options,
1213 self.cache_budget(),
1214 self.sync_data.bigram_index.as_deref(),
1215 overlay_guard.as_deref(),
1216 cancel,
1217 &self.base_path,
1218 arena,
1219 overflow_arena,
1220 )
1221 })
1222 }
1223
1224 pub fn multi_grep(
1226 &self,
1227 patterns: &[&str],
1228 constraints: &[fff_query_parser::Constraint<'_>],
1229 options: &GrepSearchOptions,
1230 ) -> GrepResult<'_> {
1231 let overlay_guard = self.sync_data.bigram_overlay.as_ref().map(|o| o.read());
1232 let arena = self.arena_base_ptr();
1233 let overflow_arena = self.sync_data.arena_overflow_ptr();
1234 let cancel = options
1235 .abort_signal
1236 .as_deref()
1237 .unwrap_or(&self.signals.cancelled);
1238
1239 SEARCH_THREAD_POOL.install(|| {
1240 multi_grep_search(
1241 self.get_files(),
1242 patterns,
1243 constraints,
1244 options,
1245 self.cache_budget(),
1246 self.sync_data.bigram_index.as_deref(),
1247 overlay_guard.as_deref(),
1248 cancel,
1249 &self.base_path,
1250 arena,
1251 overflow_arena,
1252 )
1253 })
1254 }
1255
1256 pub fn get_scan_progress(&self) -> ScanProgress {
1258 let scanned_count = self.scanned_files_count.load(Ordering::Relaxed);
1259 let is_scanning = self.signals.scanning.load(Ordering::Relaxed);
1260
1261 ScanProgress {
1262 scanned_files_count: scanned_count,
1263 is_scanning,
1264 is_watcher_ready: self.signals.watcher_ready.load(Ordering::Relaxed),
1265 is_warmup_complete: !self.enable_content_indexing
1266 || self.sync_data.bigram_index.is_some(),
1267 }
1268 }
1269
1270 pub(crate) fn set_bigram_index(&mut self, index: BigramFilter) {
1271 self.sync_data.bigram_index = Some(Arc::new(index));
1272 self.sync_data.bigram_overlay = Some(Arc::new(parking_lot::RwLock::new(
1274 BigramOverlay::new(self.sync_data.indexable_count),
1275 )));
1276 }
1277
1278 pub(crate) fn scan_signals(&self) -> crate::scan::ScanSignals {
1279 self.signals.clone()
1280 }
1281
1282 pub(crate) fn scanned_files_counter(&self) -> Arc<AtomicUsize> {
1283 Arc::clone(&self.scanned_files_count)
1284 }
1285
1286 pub(crate) unsafe fn post_scan_snapshot(&self) -> Option<PostScanUnsafeSnapshot> {
1304 if self
1305 .signals
1306 .post_scan_indexing_active
1307 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1308 .is_err()
1309 {
1310 tracing::error!(
1311 "Can not acquire post scan unsafe snapshot, someone already acquired it"
1312 );
1313 return None;
1314 }
1315
1316 Some(PostScanUnsafeSnapshot {
1317 files: self.sync_data.files.clone(),
1318 dirs: self.sync_data.dirs.clone(),
1319 arena: self.sync_data.chunked_paths.as_ref().map(Arc::clone),
1320 base_count: self.sync_data.base_count,
1321 indexable_count: self.sync_data.indexable_count,
1322 base_path: self.base_path.clone(),
1323 cancelled: Arc::clone(&self.signals.cancelled),
1324 post_scan_flag: Arc::clone(&self.signals.post_scan_indexing_active),
1325 _budget: Arc::clone(&self.cache_budget),
1326 })
1327 }
1328
1329 pub(crate) fn commit_new_sync(&mut self, sync: FileSync) {
1330 self.sync_data = sync;
1331 self.cache_budget.reset();
1332 }
1333
1334 #[inline]
1335 pub(crate) fn arena_base_ptr(&self) -> ArenaPtr {
1336 self.sync_data.arena_base_ptr()
1337 }
1338
1339 pub(crate) fn update_git_statuses(
1341 &mut self,
1342 status_cache: GitStatusCache,
1343 shared_frecency: &SharedFrecency,
1344 ) -> Result<(), Error> {
1345 debug!(
1346 statuses_count = status_cache.statuses_len(),
1347 "Updating git status",
1348 );
1349
1350 let mode = self.mode;
1351 let bp = self.base_path.clone();
1352 let frecency = shared_frecency.read()?;
1353
1354 status_cache
1355 .into_iter()
1356 .try_for_each(|(path, status)| -> Result<(), Error> {
1357 if let Some((arena, file)) = self.get_mut_file_by_path(&path) {
1358 file.git_status = Some(status);
1359 if let Some(ref f) = *frecency {
1360 file.update_frecency_scores(f, arena, &bp, mode)?;
1361 }
1362 let score = file.access_frecency_score as i32;
1366 let dir_idx = file.parent_dir_index as usize;
1367 if let Some(dir) = self.sync_data.dirs.get(dir_idx) {
1368 dir.update_frecency_if_larger(score);
1369 }
1370 } else {
1371 debug!(?path, "Git status for path not in index, skipping");
1375 }
1376 Ok(())
1377 })?;
1378
1379 Ok(())
1380 }
1381
1382 pub fn update_single_file_frecency(
1383 &mut self,
1384 file_path: impl AsRef<Path>,
1385 frecency_tracker: &FrecencyTracker,
1386 ) -> Result<(), Error> {
1387 let path = file_path.as_ref();
1388
1389 let Some(index) = self.sync_data.find_file_index(path, &self.base_path) else {
1390 return Ok(());
1391 };
1392
1393 if let Some((arena, file)) = self.sync_data.get_file_mut(index) {
1394 file.update_frecency_scores(frecency_tracker, arena, &self.base_path, self.mode)?;
1395
1396 let score = file.access_frecency_score as i32;
1398 let dir_idx = file.parent_dir_index as usize;
1399 if let Some(dir) = self.sync_data.dirs.get(dir_idx) {
1400 dir.update_frecency_if_larger(score);
1401 }
1402 }
1403
1404 Ok(())
1405 }
1406
1407 pub fn get_file_by_path(&self, path: impl AsRef<Path>) -> Option<&FileItem> {
1408 self.sync_data
1409 .find_file_index(path.as_ref(), &self.base_path)
1410 .and_then(|index| self.sync_data.files().get(index))
1411 }
1412
1413 pub fn get_mut_file_by_path(
1414 &mut self,
1415 path: impl AsRef<Path>,
1416 ) -> Option<(ArenaPtr, &mut FileItem)> {
1417 let path = path.as_ref();
1418 let index = self.sync_data.find_file_index(path, &self.base_path);
1419 index.and_then(|i| self.sync_data.get_file_mut(i))
1420 }
1421
1422 #[tracing::instrument(skip(self),level = Level::DEBUG)]
1426 pub fn handle_create_or_modify(&mut self, path: impl AsRef<Path> + Debug) -> Option<&FileItem> {
1427 let path = path.as_ref();
1428
1429 if let Some(idx) = self.sync_data.find_file_index(path, &self.base_path) {
1430 let slot = if idx < self.sync_data.base_count {
1431 FileSlot::Base(idx)
1432 } else {
1433 FileSlot::Overflow(idx)
1434 };
1435
1436 return self.handle_file_modify(path, slot);
1437 }
1438
1439 self.add_new_file(path)
1440 }
1441
1442 #[tracing::instrument(skip_all, fields(path = ?path), level = Level::DEBUG)]
1443 fn handle_file_modify(&mut self, path: &Path, slot: FileSlot) -> Option<&FileItem> {
1444 let overlay = self.sync_data.bigram_overlay.as_ref().map(Arc::clone);
1445 let pos = slot.index();
1446
1447 let metadata = match std::fs::metadata(path) {
1450 Ok(m) => {
1451 self.untombstone_file(pos);
1452
1453 m
1454 }
1455 Err(_) => {
1456 self.tombstone_file(pos);
1457 return None;
1458 }
1459 };
1460
1461 let (_arena, file) = self.sync_data.get_file_mut(pos)?;
1462
1463 let size = metadata.len();
1464 let modified_time = metadata
1465 .modified()
1466 .ok()
1467 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1468 .map(|d| d.as_secs());
1469
1470 file.update_metadata(&self.cache_budget, modified_time, Some(size));
1471
1472 if !file.is_binary() {
1475 let mut chunk = [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE];
1476 file.detect_binary_per_byte(path, &mut chunk);
1477 }
1478
1479 if matches!(slot, FileSlot::Base(_))
1481 && let Some(ref overlay) = overlay
1482 {
1483 let in_indexable = {
1484 let guard = overlay.read();
1485 pos < guard.base_file_count()
1486 };
1487
1488 if in_indexable && let Ok(content) = std::fs::read(path) {
1489 overlay.write().modify_file(pos, &content);
1490 }
1491 }
1492
1493 self.sync_data.files().get(pos)
1494 }
1495
1496 #[tracing::instrument(skip(self))]
1499 pub fn add_new_file(&mut self, path: &Path) -> Option<&FileItem> {
1500 #[cfg(windows)]
1504 let canonical_buf: Option<PathBuf> = if path.starts_with(&self.base_path) {
1505 None
1506 } else if let Ok(c) = crate::path_utils::canonicalize(path) {
1507 Some(c)
1508 } else {
1509 tracing::error!(path = ?path.display(), "Failed to canonicalize file path to add");
1510 return None;
1511 };
1512
1513 #[cfg(windows)]
1514 let path_for_index: &Path = canonical_buf.as_deref().unwrap_or(path);
1515 #[cfg(not(windows))]
1516 let path_for_index: &Path = path;
1517
1518 let (mut file_item, rel_path) =
1519 FileItem::new(path_for_index.to_path_buf(), &self.base_path, None);
1520
1521 file_item.detect_binary_per_byte(
1524 path_for_index,
1525 &mut [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE],
1527 );
1528
1529 let builder = self.sync_data.overflow_builder.get_or_insert_with(|| {
1530 crate::simd_path::ChunkedPathStoreBuilder::new(MAX_OVERFLOW_FILES)
1532 });
1533
1534 file_item.set_path(builder.add_file_immediate(&rel_path, file_item.path.filename_offset));
1535 file_item.set_overflow(true);
1536
1537 if !self.sync_data.files.push(file_item) {
1538 return None;
1539 }
1540
1541 self.sync_data.live_count += 1;
1542 self.sync_data.files.last()
1543 }
1544
1545 fn tombstone_file(&mut self, index: usize) {
1546 let file = &mut self.sync_data.files[index];
1547 if file.is_deleted() {
1548 return;
1549 }
1550
1551 file.set_deleted(true);
1552 file.invalidate_mmap(&self.cache_budget);
1553 file.git_status = None;
1554
1555 if index < self.sync_data.base_count
1557 && let Some(ref overlay) = self.sync_data.bigram_overlay
1558 {
1559 overlay.write().delete_file(index);
1560 }
1561
1562 self.sync_data.live_count -= 1;
1563 }
1564
1565 fn untombstone_file(&mut self, index: usize) {
1566 let file = &mut self.sync_data.files[index];
1567 if !file.is_deleted() {
1568 return;
1569 }
1570 file.set_deleted(false);
1571
1572 self.sync_data.live_count += 1;
1573 }
1574
1575 pub fn remove_file_by_path(&mut self, path: impl AsRef<Path>) -> bool {
1579 let path = path.as_ref();
1580 if let Some(index) = self.sync_data.find_file_index(path, &self.base_path) {
1581 self.tombstone_file(index);
1582 true
1583 } else {
1584 false
1585 }
1586 }
1587
1588 pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef<Path>) -> usize {
1590 let dir_path = dir.as_ref();
1591 let relative_dir = self
1592 .to_relative_path(dir_path)
1593 .map(|c| c.into_owned())
1594 .unwrap_or_default();
1595
1596 let dir_prefix = if relative_dir.is_empty() {
1597 String::new()
1598 } else {
1599 format!("{}{}", relative_dir, std::path::MAIN_SEPARATOR)
1600 };
1601
1602 self.sync_data.tombstone_files_with_arena(|file, arena| {
1603 file.relative_path_starts_with(arena, &dir_prefix)
1604 })
1605 }
1606
1607 pub fn cancel(&self) {
1609 self.signals.cancelled.store(true, Ordering::Release);
1610 }
1611
1612 pub fn stop_background_monitor(&mut self) {
1614 if let Some(mut watcher) = self.background_watcher.take() {
1615 watcher.stop();
1616 }
1617 }
1618
1619 pub fn is_scan_active(&self) -> bool {
1621 self.signals.scanning.load(Ordering::Relaxed)
1622 }
1623
1624 pub fn is_post_scan_active(&self) -> bool {
1625 self.signals
1626 .post_scan_indexing_active
1627 .load(Ordering::Acquire)
1628 }
1629
1630 pub fn watcher_signal(&self) -> Arc<AtomicBool> {
1633 Arc::clone(&self.signals.watcher_ready)
1634 }
1635
1636 fn to_relative_path<'a>(&self, path: &'a Path) -> Option<std::borrow::Cow<'a, str>> {
1644 if let Ok(stripped) = path.strip_prefix(&self.base_path)
1645 && let Some(s) = stripped.to_str()
1646 {
1647 return Some(std::borrow::Cow::Borrowed(s));
1648 }
1649
1650 #[cfg(windows)]
1651 {
1652 let rel = canonical_relative_path(path, &self.base_path)?;
1653 return Some(std::borrow::Cow::Owned(rel));
1654 }
1655
1656 #[cfg(not(windows))]
1657 None
1658 }
1659}
1660
1661#[cfg(windows)]
1665fn canonical_relative_path(path: &Path, base: &Path) -> Option<String> {
1666 if let Ok(canonical) = crate::path_utils::canonicalize(path)
1667 && let Ok(stripped) = canonical.strip_prefix(base)
1668 && let Some(s) = stripped.to_str()
1669 {
1670 return Some(s.to_owned());
1671 }
1672
1673 let parent = path.parent()?;
1676 let file_name = path.file_name()?;
1677 let canonical_parent = crate::path_utils::canonicalize(parent).ok()?;
1678 let stripped_parent = canonical_parent.strip_prefix(base).ok()?;
1679 let mut rel = stripped_parent.to_path_buf();
1680 rel.push(file_name);
1681 rel.to_str().map(str::to_owned)
1682}
1683
1684impl Drop for FilePicker {
1685 fn drop(&mut self) {
1686 self.signals.cancelled.store(true, Ordering::Release);
1689 }
1690}
1691
1692#[derive(Debug, Clone, Copy)]
1693enum FileSlot {
1694 Base(usize),
1695 Overflow(usize),
1696}
1697
1698impl FileSlot {
1699 fn index(self) -> usize {
1700 match self {
1701 FileSlot::Base(i) | FileSlot::Overflow(i) => i,
1702 }
1703 }
1704}
1705
1706pub(crate) struct PostScanUnsafeSnapshot {
1718 pub files: StableVec<FileItem>,
1719 pub dirs: StableVec<crate::types::DirItem>,
1720 pub arena: Option<Arc<crate::simd_path::ChunkedPathStore>>,
1721 pub _budget: Arc<crate::types::ContentCacheBudget>,
1723 pub base_count: usize,
1724 pub indexable_count: usize,
1725 pub base_path: PathBuf,
1726 pub cancelled: Arc<AtomicBool>,
1727 post_scan_flag: Arc<AtomicBool>,
1728}
1729
1730impl Drop for PostScanUnsafeSnapshot {
1731 fn drop(&mut self) {
1732 self.post_scan_flag.store(false, Ordering::Release);
1733 }
1734}
1735
1736unsafe impl Send for PostScanUnsafeSnapshot {}
1740unsafe impl Sync for PostScanUnsafeSnapshot {}
1741
1742#[derive(Debug, Clone)]
1747pub struct ScanProgress {
1748 pub scanned_files_count: usize,
1749 pub is_scanning: bool,
1750 pub is_watcher_ready: bool,
1751 pub is_warmup_complete: bool,
1752}
1753
1754impl FileSync {
1755 pub(crate) fn discover_git_workdir(base_path: &Path) -> Option<PathBuf> {
1756 let git_workdir = Repository::discover(base_path)
1757 .ok()
1758 .and_then(|repo| repo.workdir().map(Path::to_path_buf))
1759 .map(crate::path_utils::normalize);
1760
1761 match &git_workdir {
1762 Some(workdir) => debug!("Git repository found at: {}", workdir.display()),
1763 None => warn!("No git repository found for path: {}", base_path.display()),
1764 }
1765
1766 git_workdir
1767 }
1768
1769 pub(crate) fn spawn_git_status(git_workdir: PathBuf) -> JoinHandle<Option<GitStatusCache>> {
1770 std::thread::spawn(move || {
1771 GitStatusCache::read_git_status(
1772 Some(git_workdir.as_path()),
1773 &mut crate::git::initial_scan_status_options(),
1774 )
1775 })
1776 }
1777
1778 #[tracing::instrument(skip_all, name = "walk_filesystem", level = Level::INFO)]
1782 pub(crate) fn walk_filesystem(
1783 base_path: &Path,
1784 git_workdir: Option<PathBuf>,
1785 synced_files_count: &Arc<AtomicUsize>,
1786 shared_frecency: &SharedFrecency,
1787 mode: FFFMode,
1788 follow_symlinks: bool,
1789 ) -> Result<FileSync, Error> {
1790 use ignore::WalkBuilder;
1791
1792 let scan_start = std::time::Instant::now();
1793 info!("SCAN: Starting filesystem walk and git status (async)");
1794
1795 let is_git_repo = git_workdir.is_some();
1797 let bg_threads = BACKGROUND_THREAD_POOL.current_num_threads();
1798
1799 let mut walk_builder = WalkBuilder::new(base_path);
1800 walk_builder
1801 .hidden(!is_git_repo)
1803 .git_ignore(true)
1804 .git_exclude(true)
1805 .git_global(true)
1806 .ignore(true)
1807 .follow_links(follow_symlinks)
1808 .threads(bg_threads);
1809
1810 if !is_git_repo && let Some(overrides) = non_git_repo_overrides(base_path) {
1811 walk_builder.overrides(overrides);
1812 }
1813
1814 let walker = walk_builder.build_parallel();
1815 let walker_start = std::time::Instant::now();
1816 debug!("SCAN: Starting file walker");
1817
1818 let pairs = parking_lot::Mutex::new(Vec::<(FileItem, String)>::new());
1821
1822 let walker_span = tracing::info_span!("walker_run").entered();
1823 walker.run(|| {
1824 let pairs = &pairs;
1825 let counter = Arc::clone(synced_files_count);
1826 let base_path = base_path.to_path_buf();
1827
1828 Box::new(move |result| {
1829 let Ok(entry) = result else {
1830 return ignore::WalkState::Continue;
1831 };
1832
1833 if entry.file_type().is_some_and(|ft| ft.is_file()) {
1834 let path = entry.path();
1835
1836 if is_git_file(path) {
1839 return ignore::WalkState::Continue;
1840 }
1841
1842 if !is_git_repo && is_known_binary_extension(path) {
1843 return ignore::WalkState::Continue;
1844 }
1845
1846 let metadata = entry.metadata().ok();
1847 let (file_item, rel_path) =
1848 FileItem::new_from_walk(path, &base_path, None, metadata.as_ref());
1849
1850 pairs.lock().push((file_item, rel_path));
1851 counter.fetch_add(1, Ordering::Relaxed);
1852 }
1853 ignore::WalkState::Continue
1854 })
1855 });
1856 drop(walker_span);
1857
1858 let mut pairs = pairs.into_inner();
1859 info!(
1860 "SCAN: File walking completed in {:?} for {} files",
1861 walker_start.elapsed(),
1862 pairs.len(),
1863 );
1864
1865 BACKGROUND_THREAD_POOL.install(|| {
1869 pairs.par_sort_unstable_by(|(a, path_a), (b, path_b)| {
1870 let (a_dir, a_file) = path_a.split_at(a.path.filename_offset as usize);
1872 let (b_dir, b_file) = path_b.split_at(b.path.filename_offset as usize);
1873 a_dir.cmp(b_dir).then_with(|| a_file.cmp(b_file))
1874 });
1875 });
1876
1877 let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len());
1878 let dirs = populates_dirs_files_chunked_storage(&mut pairs, &mut builder);
1879
1880 let mut files: Vec<FileItem> = pairs.into_iter().map(|(file, _)| file).collect();
1881 let chunked_paths = builder.finish();
1882 let arena = chunked_paths.as_arena_ptr();
1883
1884 let frecency = shared_frecency
1887 .read()
1888 .map_err(|_| Error::AcquireFrecencyLock)?;
1889
1890 if let Some(frecency) = frecency.as_ref() {
1891 let dirs_ref = &dirs;
1892 BACKGROUND_THREAD_POOL.install(|| {
1893 files.par_iter_mut().for_each(|file| {
1894 let _ = file.update_frecency_scores(frecency, arena, base_path, mode);
1895 let score = file.access_frecency_score as i32;
1896 if score > 0 {
1897 let dir_idx = file.parent_dir_index as usize;
1898 if let Some(dir) = dirs_ref.get(dir_idx) {
1899 dir.update_frecency_if_larger(score);
1900 }
1901 }
1902 });
1903 });
1904 }
1905 drop(frecency);
1906
1907 let is_indexable = |f: &FileItem| {
1909 !f.is_binary()
1910 && f.size > 0
1911 && f.size <= crate::constants::MAX_INDEXABLE_FILE_SIZE as u64
1912 };
1913
1914 BACKGROUND_THREAD_POOL.install(|| {
1915 files.par_sort_unstable_by(|a, b| {
1916 (!is_indexable(a))
1917 .cmp(&!is_indexable(b))
1918 .then_with(|| a.parent_dir_index.cmp(&b.parent_dir_index))
1920 .then_with(|| a.file_name(arena).cmp(&b.file_name(arena)))
1921 });
1922 });
1923 let indexable_count = files.partition_point(is_indexable);
1924
1925 hint_allocator_collect();
1927
1928 let file_item_size = std::mem::size_of::<FileItem>();
1929 let files_vec_bytes = files.len() * file_item_size;
1930 let dir_table_bytes = dirs.len() * std::mem::size_of::<DirItem>()
1931 + dirs
1932 .iter()
1933 .map(|d| d.relative_path(arena).len())
1934 .sum::<usize>();
1935
1936 let total_time = scan_start.elapsed();
1937 info!(
1938 "SCAN: Walk completed in {:?} ({} files, {} dirs, \
1939 chunked_store={:.2}MB, files_vec={:.2}MB, dirs={:.2}MB, FileItem={}B)",
1940 total_time,
1941 files.len(),
1942 dirs.len(),
1943 chunked_paths.heap_bytes() as f64 / 1_048_576.0,
1944 files_vec_bytes as f64 / 1_048_576.0,
1945 dir_table_bytes as f64 / 1_048_576.0,
1946 file_item_size,
1947 );
1948
1949 let base_count = files.len();
1950
1951 Ok(FileSync {
1952 files: StableVec::from_vec_with_reserve(files, MAX_OVERFLOW_FILES),
1953 indexable_count,
1954 base_count,
1955 live_count: base_count,
1956 dirs: StableVec::from_vec_with_reserve(dirs, 0),
1957 overflow_builder: None,
1958 git_workdir,
1959 bigram_index: None,
1960 bigram_overlay: None,
1961 chunked_paths: Some(Arc::new(chunked_paths)),
1962 })
1963 }
1964}
1965
1966#[allow(dead_code)]
1969#[tracing::instrument(skip(files), name = "warmup_mmaps", level = Level::DEBUG)]
1970pub(crate) fn warmup_mmaps(
1971 files: &[FileItem],
1972 budget: &ContentCacheBudget,
1973 base_path: &Path,
1974 arena: ArenaPtr,
1975) {
1976 for file in files.iter() {
1978 if file.is_likely_hot()
1979 || file.is_binary()
1980 || file.size == 0
1981 || file.size > budget.max_file_size
1982 {
1983 continue;
1984 }
1985
1986 let _ = file.get_cached_content(arena, base_path, budget);
1987
1988 if budget.is_exhausted() {
1989 break;
1990 }
1991 }
1992}
1993
1994fn populates_dirs_files_chunked_storage<'a>(
1997 pairs: &'a mut [(FileItem, String)],
1998 chunk_storage: &mut crate::simd_path::ChunkedPathStoreBuilder,
1999) -> Vec<DirItem> {
2000 let mut dirs: Vec<DirItem> = Vec::new();
2001
2002 let mut prev_dir: &'a str = "";
2003 let mut prev_dir_valid = false;
2004 let mut current_dir_idx: u32 = 0;
2005
2006 for (file, rel) in pairs.iter_mut() {
2007 let rel: &'a str = rel;
2008 let dir_part: &'a str = &rel[..file.path.filename_offset as usize];
2009
2010 if !prev_dir_valid || prev_dir != dir_part {
2011 let dir_string = chunk_storage.add_dir_immediate(dir_part);
2012
2013 let last_seg = if dir_part.is_empty() {
2015 0
2016 } else {
2017 let trimmed = dir_part.trim_end_matches(std::path::is_separator);
2018 trimmed
2019 .rfind(std::path::is_separator)
2020 .map(|i| i + 1)
2021 .unwrap_or(0) as u16
2022 };
2023
2024 dirs.push(DirItem::new(dir_string, last_seg));
2025 current_dir_idx = (dirs.len() - 1) as u32;
2026
2027 prev_dir = dir_part;
2028 prev_dir_valid = true;
2029 }
2030
2031 file.path = chunk_storage.add_file_immediate(rel, file.path.filename_offset);
2032 file.parent_dir_index = current_dir_idx;
2033 }
2034
2035 dirs
2036}
2037
2038#[inline]
2041#[doc(hidden)]
2042pub fn is_known_binary_extension(path: &Path) -> bool {
2043 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
2044 return false;
2045 };
2046
2047 matches!(
2048 ext,
2049 "png" | "jpg" | "jpeg" | "gif" | "bmp" | "ico" | "webp" | "tiff" | "tif" | "avif" |
2051 "heic" | "heif" | "jxl" | "jp2" | "j2k" | "psd" | "icns" | "cur" | "cr2" |
2052 "nef" | "dng" | "tga" |
2053 "rgbe" | "hdr" | "exr" | "dds" | "ktx" | "ktx2" | "pvr" | "astc" |
2055 "ai" | "webarchive" | "mhtml" |
2057 "mp4" | "avi" | "mov" | "wmv" | "mkv" | "mp3" | "wav" | "flac" | "ogg" | "m4a" |
2059 "aac" | "webm" | "flv" | "mpg" | "mpeg" | "wma" | "opus" | "pcm" | "reapeaks" |
2060 "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" | "zst" | "lz4" | "lzma" |
2062 "cab" | "cpio" | "jsonlz4" |
2063 "deb" | "rpm" | "apk" | "dmg" | "msi" | "iso" | "nupkg" | "whl" | "egg" |
2065 "appimage" | "flatpak" | "crx" | "pak" |
2066 "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib" | "bin" | "elf" |
2068 "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" |
2070 "db" | "sqlite" | "sqlite3" | "mdb" |
2072 "sqlite-wal" | "sqlite-shm" | "sqlite3-wal" | "sqlite3-shm" |
2074 "db-wal" | "db-shm" | "ldb" |
2075 "ttf" | "otf" | "woff" | "woff2" | "eot" |
2077 "class" | "pyc" | "pyo" | "wasm" | "dex" | "jar" | "war" |
2079 "cmi" | "cmt" | "cmti" | "cmx" | "nib" |
2081 "swiftdeps" | "swiftdeps~" | "swiftdoc" | "swiftmodule" | "swiftsourceinfo" |
2082 "npy" | "npz" | "h5" | "hdf5" | "pt" | "onnx" |
2084 "safetensors" | "tfrecord" | "tflite" | "gguf" | "ggml" | "joblib" |
2085 "glb" | "blend" | "blp" |
2087 "dia" | "bcmap" |
2089 "pb" |
2091 "parquet" | "arrow" |
2093 "suo"
2095 )
2096}
2097
2098fn common_dir_prefix_len(a: &str, b: &str) -> usize {
2112 let max = a.len().min(b.len());
2113 let a_bytes = a.as_bytes();
2114 let b_bytes = b.as_bytes();
2115 let mut last_sep = 0;
2116 let mut i = 0;
2117 while i < max && a_bytes[i] == b_bytes[i] {
2118 if std::path::is_separator(a_bytes[i] as char) {
2119 last_sep = i + 1;
2120 }
2121 i += 1;
2122 }
2123 if i == max && i > 0 {
2126 let longer = if a.len() > b.len() { a_bytes } else { b_bytes };
2127 if i < longer.len() && std::path::is_separator(longer[i] as char) {
2128 return i;
2129 }
2130 }
2131 last_sep
2132}
2133
2134pub(crate) fn hint_allocator_collect() {
2138 #[cfg(feature = "mimalloc-collect")]
2139 {
2140 BACKGROUND_THREAD_POOL.broadcast(|_| unsafe { libmimalloc_sys::mi_collect(true) });
2144
2145 unsafe { libmimalloc_sys::mi_collect(true) };
2147 }
2148}
2149
2150#[cfg(test)]
2151mod tests {
2152 use super::*;
2153
2154 #[test]
2163 fn extract_watch_dirs_includes_pure_ancestor_dirs() {
2164 let dir = tempfile::tempdir().unwrap();
2165 let base_buf = crate::path_utils::canonicalize(dir.path()).unwrap();
2169 let base = base_buf.as_path();
2170
2171 for rel in [
2187 "src/components/button.txt",
2188 "src/routes/home.txt",
2189 "lib/deep/nested/util.txt",
2190 ] {
2191 let path = base.join(rel);
2192 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2193 std::fs::write(&path, b"x").unwrap();
2194 }
2195
2196 let mut picker = FilePicker::new(FilePickerOptions {
2197 base_path: base.to_str().unwrap().into(),
2198 watch: false,
2199 ..Default::default()
2200 })
2201 .unwrap();
2202 picker.collect_files().unwrap();
2203
2204 let mut watch_dirs: Vec<PathBuf> = Vec::new();
2205 picker.for_each_dir(|p| {
2206 watch_dirs.push(p.to_path_buf());
2207 std::ops::ControlFlow::Continue(())
2208 });
2209 let watch_set: std::collections::HashSet<PathBuf> = watch_dirs.iter().cloned().collect();
2210
2211 for rel in ["src/components", "src/routes", "lib/deep/nested"] {
2213 assert!(
2214 watch_set.contains(&base.join(rel)),
2215 "expected immediate parent {rel} in watch dirs, got {watch_set:?}",
2216 );
2217 }
2218
2219 for rel in ["src", "lib", "lib/deep"] {
2221 assert!(
2222 watch_set.contains(&base.join(rel)),
2223 "expected pure-ancestor {rel} in watch dirs, got {watch_set:?}",
2224 );
2225 }
2226
2227 assert_eq!(
2229 watch_dirs.len(),
2230 watch_set.len(),
2231 "duplicate watch dir emitted: {watch_dirs:?}",
2232 );
2233
2234 assert!(
2238 !watch_set.contains(base),
2239 "base path must not be in watch dirs (covered by the top-level watch call)",
2240 );
2241 }
2242
2243 #[test]
2244 fn common_dir_prefix_len_cases() {
2245 assert_eq!(common_dir_prefix_len("", ""), 0);
2246 assert_eq!(common_dir_prefix_len("", "src"), 0);
2247 assert_eq!(common_dir_prefix_len("lib", "src"), 0);
2248 assert_eq!(common_dir_prefix_len("src/components", "src/routes"), 4);
2249 assert_eq!(common_dir_prefix_len("lib/deep/nested", "lib/deep"), 8);
2250 assert_eq!(common_dir_prefix_len("lib/deep", "lib/deep/nested"), 8);
2251 assert_eq!(common_dir_prefix_len("lib/deep", "lib/deeper"), 4);
2252 assert_eq!(common_dir_prefix_len("src", "src"), 0);
2253 assert_eq!(common_dir_prefix_len("src", "src/x"), 3);
2255 }
2256}