Skip to main content

fff_search/
file_picker.rs

1//! Core file picker: filesystem indexing, background watching, and fuzzy search.
2//!
3//! [`FilePicker`] is the central component of fff-search. It:
4//!
5//! 1. **Indexes** a directory tree in a background thread, collecting every
6//!    non-ignored file into a path-sorted `Vec<FileItem>`.
7//! 2. **Watches** the filesystem via the `notify` crate, applying
8//!    create/modify/delete events to the index in real time.
9//! 3. **Owns files**: Provides a values for search and provides a good entry point for
10//!    fuzzy search and live grep
11//!
12//! # Lifecycle
13//!
14//! ```text
15//!   new_with_shared_state()
16//!     │
17//!     ├─> background scan thread ──> populates SharedPicker
18//!     └─> file-system watcher    ──> live updates SharedPicker
19//!
20//!   search()         <── borrows &self, delegates to fuzzy_search
21//!   grep()           <── static, borrows &[FileItem] (live content search)
22//!   trigger_rescan() <── synchronous re-index
23//!   cancel()         <── shuts down background work
24//! ```
25//!
26//! # Thread Safety
27//!
28//! `FilePicker` itself is **not** `Sync`!
29//! all concurrent access goes through [`crate::SharedFilePicker`]
30
31use crate::FFFStringStorage;
32use crate::constants::{MAX_OVERFLOW_FILES, PATH_BUF_SIZE};
33use crate::error::Error;
34use crate::frecency::FrecencyTracker;
35use crate::git::GitStatusCache;
36use crate::git_recency::{self, GitRecencyConfig};
37use crate::grep::{GrepResult, GrepSearchOptions, grep_search, multi_grep_search};
38use crate::index::{BigramFilter, BigramOverlay};
39use crate::query_tracker::QueryTracker;
40use crate::scan::{ScanConfig, ScanJob, ScanSignals};
41use crate::score::{fuzzy_match_and_score_files, fuzzy_match_byte_offsets_for_page};
42use crate::shared::{SharedFilePicker, SharedFrecency};
43use crate::simd_path::ArenaPtr;
44use crate::stable_vec::StableVec;
45use crate::types::{
46    ContentCacheBudget, DirItem, DirSearchResult, FileItem, MixedItemRef, MixedSearchResult,
47    PaginationArgs, Score, ScoringContext, SearchResult,
48};
49use crate::walk::WalkOutput;
50use crate::watch::BackgroundWatcher;
51use ahash::AHashMap;
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/// Configuration for a single fuzzy search invocation.
82///
83/// Passed to [`FilePicker::search`] to control threading, pagination,
84/// and scoring behavior.
85#[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    /// Base files laid out in two partitions, each internally sorted by
99    /// (parent_dir, filename):
100    ///   `files[..indexable_count]` - indexable
101    ///   `files[indexable_count..base_count]` - original-unindexable
102    ///   `files[base_count..]` - overflow
103    files: StableVec<FileItem>,
104    indexable_count: usize,
105    base_count: usize,
106    /// Number of active present files that exists in the file system
107    pub(crate) live_count: usize,
108    /// Sorted directory table. `StableVec` so post-scan snapshots can keep
109    /// the allocation alive across a picker drop without copying, and so
110    /// concurrent readers observe a consistent view via the same shared
111    /// allocation. Dir frecency is updated through the per-entry atomic
112    /// (`DirItem::max_access_frecency`) without `&mut` aliasing.
113    /// Layout mirrors `files`: `dirs[..base_dirs_count]` is the sorted
114    /// scan-built region, `dirs[base_dirs_count..]` holds watcher-appended dirs.
115    dirs: StableVec<DirItem>,
116    base_dirs_count: usize,
117    /// Number of dirs with at least one live file (mirrors `live_count`).
118    live_dirs_count: usize,
119    /// Shared builder for overflow file paths. Each overflow file's ChunkedString
120    /// uses `arena_override` pointing into this builder's arena.
121    overflow_builder: Option<crate::simd_path::ChunkedPathStoreBuilder>,
122    bigram_index: Option<Arc<BigramFilter>>,
123    bigram_overlay: Option<Arc<parking_lot::RwLock<BigramOverlay>>>,
124    /// Chunk-level deduped path store. Arc so post-scan snapshots can hold
125    /// the arena alive while iterating file paths.
126    chunked_paths: Option<Arc<crate::simd_path::ChunkedPathStore>>,
127    /// Ignore rules the walker assembled (zlob backend only). Shared with the
128    /// background watcher so filesystem events can be filtered without libgit2.
129    pub(crate) ignore_rules: Option<Arc<crate::walk::WalkIgnoreRules>>,
130}
131
132impl FileSync {
133    fn new() -> Self {
134        Self {
135            files: StableVec::from_vec_with_reserve(Vec::new(), MAX_OVERFLOW_FILES),
136            indexable_count: 0,
137            base_count: 0,
138            live_count: 0,
139            dirs: StableVec::from_vec_with_reserve(Vec::new(), MAX_OVERFLOW_FILES),
140            base_dirs_count: 0,
141            live_dirs_count: 0,
142            overflow_builder: None,
143            git_workdir: None,
144            bigram_index: None,
145            bigram_overlay: None,
146            chunked_paths: None,
147            ignore_rules: None,
148        }
149    }
150
151    #[inline]
152    fn arena_base_ptr(&self) -> ArenaPtr {
153        self.chunked_paths
154            .as_ref()
155            .map(|s| s.as_arena_ptr())
156            .unwrap_or(ArenaPtr::null())
157    }
158
159    #[inline]
160    fn arena_overflow_ptr(&self) -> ArenaPtr {
161        self.overflow_builder
162            .as_ref()
163            .map(|b| b.as_arena_ptr())
164            .unwrap_or(ArenaPtr::null())
165    }
166
167    #[inline]
168    fn arena_for_file(&self, file: &FileItem) -> ArenaPtr {
169        if file.is_overflow() {
170            self.arena_overflow_ptr()
171        } else {
172            self.arena_base_ptr()
173        }
174    }
175
176    #[inline]
177    fn files(&self) -> &[FileItem] {
178        &self.files
179    }
180
181    #[inline]
182    fn overflow_files(&self) -> &[FileItem] {
183        &self.files[self.base_count..]
184    }
185
186    #[inline]
187    fn get_file_mut(&mut self, index: usize) -> Option<(ArenaPtr, &mut FileItem)> {
188        Some((
189            if index < self.base_count {
190                self.arena_base_ptr()
191            } else {
192                self.arena_overflow_ptr()
193            },
194            self.files.get_mut(index)?,
195        ))
196    }
197
198    #[inline]
199    fn find_file_index(&self, path: &Path, base_path: &Path) -> Option<usize> {
200        // Strip base_path prefix to get the relative path. On Windows this
201        // can fail for 8.3 short names or a different casing; fall back to
202        // canonicalize-then-strip so watcher events still land on the right
203        // `FileItem`.
204        let rel_path_owned: String = match path.strip_prefix(base_path) {
205            Ok(r) => r.to_string_lossy().into_owned(),
206            Err(_) => {
207                #[cfg(windows)]
208                {
209                    canonical_relative_path(path, base_path)?
210                }
211                #[cfg(not(windows))]
212                {
213                    return None;
214                }
215            }
216        };
217        // The dir table and stored file paths are '/'-canonical; fold the
218        // native relative path so the byte-wise comparisons below match.
219        self.find_by_relative_path(&crate::path_utils::to_canonical_slashes(&rel_path_owned))
220    }
221
222    // Lookup for a base-relative, '/'-canonical path — the form paths are
223    // stored in, so no normalization is needed.
224    fn find_by_relative_path(&self, rel_path: &str) -> Option<usize> {
225        let arena = self.arena_base_ptr();
226
227        // Split into directory (with trailing '/') and filename.
228        let parent_end = rel_path
229            .rfind(std::path::is_separator)
230            .map(|i| i + 1)
231            .unwrap_or(0);
232        let dir_rel = &rel_path[..parent_end];
233        let filename = &rel_path[parent_end..];
234
235        // Binary search dirs to find the parent directory index.
236        // Dir items store the relative path including trailing '/' (e.g. "src/components/").
237        // Only the scan-built region is sorted; watcher-appended dirs are not.
238        let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
239        let dir_idx = self.dirs[..self.base_dirs_count]
240            .binary_search_by(|d| d.read_relative_path(arena, &mut dir_buf).cmp(dir_rel))
241            .ok();
242
243        if let Some(dir_idx) = dir_idx {
244            let dir_idx = dir_idx as u32;
245            let cmp_key = |f: &FileItem| {
246                f.parent_dir_index.cmp(&dir_idx).then_with(|| {
247                    let fname = f.file_name(arena);
248                    fname.as_str().cmp(filename)
249                })
250            };
251
252            if self.indexable_count > 0
253                && let Ok(pos) = self.files[..self.indexable_count].binary_search_by(cmp_key)
254            {
255                return Some(pos);
256            }
257
258            if self.indexable_count < self.base_count
259                && let Ok(rel_pos) =
260                    self.files[self.indexable_count..self.base_count].binary_search_by(cmp_key)
261            {
262                return Some(self.indexable_count + rel_pos);
263            }
264        }
265
266        // Overflow region: linear scan by full relative path.
267        if self.base_count < self.files.len() {
268            let overflow_arena = self.arena_overflow_ptr();
269            if let Some(pos) = self.files[self.base_count..]
270                .iter()
271                .position(|f| f.relative_path_eq(overflow_arena, rel_path))
272            {
273                return Some(self.base_count + pos);
274            }
275        }
276
277        None
278    }
279
280    // TODO remove this function and make a better way to remove all files
281    // from the directory without looping over the whole sync data list
282    // Tombstones every matching arena file.
283    fn tombstone_files_with_arena<F, T>(&mut self, mut predicate: F, mut on_tombstone: T) -> usize
284    where
285        F: FnMut(&FileItem, ArenaPtr) -> bool,
286        T: FnMut(&mut FileItem, ArenaPtr),
287    {
288        let base_arena = self.arena_base_ptr();
289        let overflow_arena = self.arena_overflow_ptr();
290        let base_count = self.base_count;
291
292        let mut tombstoned = 0usize;
293        for (idx, file) in self.files.iter_mut().enumerate() {
294            if file.is_deleted() {
295                continue;
296            }
297            let arena = if idx < base_count {
298                base_arena
299            } else {
300                overflow_arena
301            };
302            if predicate(file, arena) {
303                on_tombstone(file, arena);
304                file.set_deleted(true);
305                tombstoned += 1;
306            }
307        }
308        self.live_count -= tombstoned;
309        tombstoned
310    }
311
312    /// Marks every dir matching `predicate` as deleted. Mirrors how dir-level
313    /// FS events (remove/move-out) invalidate whole subtrees.
314    fn tombstone_dirs_with_arena<F>(&mut self, mut predicate: F)
315    where
316        F: FnMut(&DirItem, ArenaPtr) -> bool,
317    {
318        let base_arena = self.arena_base_ptr();
319        let overflow_arena = self.arena_overflow_ptr();
320        let base_dirs_count = self.base_dirs_count;
321
322        let mut removed = 0usize;
323        for (idx, dir) in self.dirs.iter_mut().enumerate() {
324            if dir.is_deleted() {
325                continue;
326            }
327            let arena = if idx < base_dirs_count {
328                base_arena
329            } else {
330                overflow_arena
331            };
332            if predicate(dir, arena) && dir.set_deleted(true) {
333                removed += 1;
334            }
335        }
336        self.live_dirs_count -= removed;
337    }
338
339    /// Restores a dir to the live state (file appeared under it again).
340    fn revive_dir(&mut self, dir_idx: u32) {
341        if let Some(dir) = self.dirs.get_mut(dir_idx as usize)
342            && dir.set_deleted(false)
343        {
344            self.live_dirs_count += 1;
345        }
346    }
347
348    /// Finds the dir index for a '/'-canonical relative dir path
349    /// (with trailing '/', empty string for the base dir itself).
350    fn find_dir_index(&self, dir_rel: &str) -> Option<usize> {
351        let arena = self.arena_base_ptr();
352        let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
353        if let Ok(idx) = self.dirs[..self.base_dirs_count]
354            .binary_search_by(|d| d.read_relative_path(arena, &mut dir_buf).cmp(dir_rel))
355        {
356            return Some(idx);
357        }
358
359        // Watcher-appended region: unsorted, small (bounded by overflow cap).
360        let overflow_arena = self.arena_overflow_ptr();
361        self.dirs[self.base_dirs_count..]
362            .iter()
363            .position(|d| d.read_relative_path(overflow_arena, &mut dir_buf) == dir_rel)
364            .map(|pos| self.base_dirs_count + pos)
365    }
366
367    /// Finds or appends the DirItem for `dir_rel`, returning its index.
368    /// `None` when the dir table's overflow capacity is exhausted.
369    fn find_or_add_dir(&mut self, dir_rel: &str) -> Option<u32> {
370        if let Some(idx) = self.find_dir_index(dir_rel) {
371            return Some(idx as u32);
372        }
373
374        let builder = self.overflow_builder.get_or_insert_with(|| {
375            crate::simd_path::ChunkedPathStoreBuilder::new(MAX_OVERFLOW_FILES)
376        });
377        let chunked = builder.add_dir_immediate(dir_rel);
378
379        let last_seg = if dir_rel.is_empty() {
380            0
381        } else {
382            let trimmed = dir_rel.trim_end_matches(std::path::is_separator);
383            trimmed
384                .rfind(std::path::is_separator)
385                .map(|i| i + 1)
386                .unwrap_or(0) as u16
387        };
388
389        let idx = self.dirs.len();
390        if !self.dirs.push(DirItem::new_overflow(chunked, last_seg)) {
391            return None;
392        }
393        self.live_dirs_count += 1;
394        Some(idx as u32)
395    }
396}
397
398impl FileItem {
399    pub fn new(path: PathBuf, base_path: &Path, git_status: Option<Status>) -> (Self, String) {
400        let metadata = std::fs::metadata(&path).ok();
401        Self::new_with_metadata(path, base_path, git_status, metadata.as_ref())
402    }
403
404    /// Create a FileItem using pre-fetched metadata to avoid a redundant stat syscall.
405    /// Returns `(FileItem, relative_path)`. The FileItem's `path` field is
406    /// empty; callers must populate it via `set_path` or `build_chunked_path_store_and_assign`.
407    fn new_with_metadata(
408        path: PathBuf,
409        base_path: &Path,
410        git_status: Option<Status>,
411        metadata: Option<&std::fs::Metadata>,
412    ) -> (Self, String) {
413        let path_buf = pathdiff::diff_paths(&path, base_path).unwrap_or_else(|| path.clone());
414        // The index is '/'-canonical on every platform; fold native separators.
415        let relative_path =
416            crate::path_utils::to_canonical_slashes(&path_buf.to_string_lossy()).into_owned();
417
418        let (size, modified) = match metadata {
419            Some(metadata) => {
420                let size = metadata.len();
421                let modified = metadata
422                    .modified()
423                    .ok()
424                    .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
425                    .map_or(0, |d| d.as_secs());
426
427                (size, modified)
428            }
429            None => (0, 0),
430        };
431
432        let is_binary = is_known_binary_extension(&path);
433
434        let filename_start = relative_path
435            .rfind(std::path::is_separator)
436            .map(|i| i + 1)
437            .unwrap_or(0) as u16;
438
439        let item = Self::new_raw(filename_start, size, modified, git_status, is_binary);
440        (item, relative_path)
441    }
442
443    /// Create a FileItem with an empty ChunkedString from a path on disk.
444    ///
445    /// Returns `(file_item, relative_path_string)`. The relative path must be
446    /// kept alongside the FileItem until `build_chunked_path_store_and_assign`
447    /// populates each item's `path` field from the shared arena.
448    pub fn new_from_walk(
449        path: &Path,
450        base_path: &Path,
451        git_status: Option<Status>,
452        metadata: Option<&std::fs::Metadata>,
453    ) -> (Self, String) {
454        let (size, modified) = match metadata {
455            Some(metadata) => {
456                let size = metadata.len();
457                let modified = metadata
458                    .modified()
459                    .ok()
460                    .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
461                    .map_or(0, |d| d.as_secs());
462                (size, modified)
463            }
464            None => (0, 0),
465        };
466
467        Self::new_from_walk_parts(path, base_path, git_status, size, modified)
468    }
469
470    /// Like [`Self::new_from_walk`] but takes already-extracted size and
471    /// modification time (Unix seconds) instead of a `std::fs::Metadata`.
472    /// Used by the zlob walker backend, which fetches metadata in bulk.
473    pub fn new_from_walk_parts(
474        path: &Path,
475        base_path: &Path,
476        git_status: Option<Status>,
477        size: u64,
478        modified: u64,
479    ) -> (Self, String) {
480        let is_binary = is_known_binary_extension(path);
481
482        let rel = pathdiff::diff_paths(path, base_path).unwrap_or_else(|| path.to_path_buf());
483        // The index is '/'-canonical on every platform; fold native separators.
484        let rel_str = crate::path_utils::to_canonical_slashes(&rel.to_string_lossy()).into_owned();
485        let fname_offset = rel_str
486            .rfind(std::path::is_separator)
487            .map(|i| i + 1)
488            .unwrap_or(0) as u16;
489
490        let item = Self::new_raw(fname_offset, size, modified, git_status, is_binary);
491        (item, rel_str)
492    }
493
494    /// Zlob-walker fast path: skip the `pathdiff::diff_paths` PathBuf alloc by
495    /// taking the already-relative slice and the basename-offset that zlob's
496    /// scanner computed during traversal. ~80–120 ms saved on a chromium scan
497    /// (500k entries × one fewer alloc + no component walk).
498    ///
499    /// `relative_path` is root-relative bytes; `basename_offset` is the byte
500    /// offset where the basename begins (e.g. zlob's `entry.path_bytes().len()
501    /// - entry.file_name().as_os_str().as_encoded_bytes().len()` minus the
502    /// `relative_offset`).
503    pub fn new_from_walk_bytes(
504        path: &Path,
505        relative_path: &[u8],
506        basename_offset: u16,
507        git_status: Option<Status>,
508        size: u64,
509        modified: u64,
510    ) -> (Self, String) {
511        let is_binary = is_known_binary_extension(path);
512        // SAFETY-ish: paths on macOS/Linux are bytes; lossy conversion mirrors
513        // the existing `to_string_lossy()` behavior on non-UTF8 names.
514        let decoded = String::from_utf8_lossy(relative_path).into_owned();
515        // The caller's offset indexes the raw bytes; re-measure it against the
516        // decoded string so it never lands inside a U+FFFD (#799).
517        let dir_bytes = relative_path
518            .get(..basename_offset as usize)
519            .unwrap_or(relative_path);
520        let basename_offset = if std::str::from_utf8(dir_bytes).is_ok() {
521            basename_offset
522        } else {
523            String::from_utf8_lossy(dir_bytes).len() as u16
524        };
525        let item = Self::new_raw(basename_offset, size, modified, git_status, is_binary);
526        (item, decoded)
527    }
528
529    pub(crate) fn update_frecency_scores(
530        &mut self,
531        tracker: &FrecencyTracker,
532        arena: ArenaPtr,
533        base_path: &Path,
534        mode: FFFMode,
535    ) -> Result<(), Error> {
536        let mut abs_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
537        let abs = self.write_absolute_path(arena, base_path, &mut abs_buf);
538        self.access_frecency_score = tracker.get_access_score(abs, mode) as i16;
539        self.modification_frecency_score =
540            tracker.get_modification_score(self.modified, self.git_status, mode) as i16;
541
542        Ok(())
543    }
544}
545
546/// Options for creating a [`FilePicker`].
547pub struct FilePickerOptions {
548    pub base_path: String,
549    /// Pre-populate mmap caches for top-frecency files after the initial scan
550    pub enable_mmap_cache: bool,
551    /// Build content index after the initial scan for faster content-aware filtering
552    pub enable_content_indexing: bool,
553    /// Mode of the picker impact the way file watcher events are handled and the scoring logic
554    pub mode: FFFMode,
555    /// Explicit cache budget. When `None`, the budget is auto-computed from
556    /// the repo size after the initial scan completes.
557    pub cache_budget: Option<ContentCacheBudget>,
558    /// When `false` no background watcher will be created
559    pub watch: bool,
560    /// Follow symbolic links during file indexing
561    pub follow_symlinks: bool,
562    /// Allow indexing the filesystem root (`/`)
563    pub enable_fs_root_scanning: bool,
564    /// Allow indexing the user's home directory. Off by default for the same
565    /// reason as `enable_fs_root_scanning`
566    pub enable_home_dir_scanning: bool,
567    /// Ranking boost for files that participated in recent commits of the
568    /// current branch. Enabled with default limits unless overridden.
569    pub git_recency: GitRecencyConfig,
570}
571
572impl Default for FilePickerOptions {
573    fn default() -> Self {
574        Self {
575            base_path: ".".into(),
576            enable_mmap_cache: false,
577            enable_content_indexing: false,
578            mode: FFFMode::default(),
579            cache_budget: None,
580            watch: true,
581            follow_symlinks: false,
582            enable_fs_root_scanning: false,
583            enable_home_dir_scanning: false,
584            git_recency: GitRecencyConfig::default(),
585        }
586    }
587}
588
589pub struct FilePicker {
590    pub mode: FFFMode,
591    pub base_path: PathBuf,
592    sync_data: FileSync,
593    pub(crate) signals: ScanSignals,
594    pub(crate) background_watcher: Option<BackgroundWatcher>,
595    /// Single serialized writer for all git-status updates (scan, watcher,
596    /// FFI). Owned by the picker so it exists before the first scan; its
597    /// consumer thread is spawned lazily once a git workdir is discovered.
598    pub(crate) git_status_worker: Arc<crate::git_status_worker::GitStatusWorker>,
599    cache_budget: Arc<ContentCacheBudget>,
600    has_explicit_cache_budget: bool,
601    scanned_files_count: Arc<AtomicUsize>,
602    enable_mmap_cache: bool,
603    enable_content_indexing: bool,
604    watch: bool,
605    follow_symlinks: bool,
606    enable_fs_root_scanning: bool,
607    enable_home_dir_scanning: bool,
608    git_recency_config: GitRecencyConfig,
609    trace_span: tracing::Span,
610    trace_id: String,
611}
612
613impl std::fmt::Debug for FilePicker {
614    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
615        f.debug_struct("FilePicker")
616            .field("base_path", &self.base_path)
617            .field("sync_data", &self.sync_data)
618            .field(
619                "is_scanning",
620                &self.signals.scanning.load(Ordering::Relaxed),
621            )
622            .field(
623                "scanned_files_count",
624                &self.scanned_files_count.load(Ordering::Relaxed),
625            )
626            .finish_non_exhaustive()
627    }
628}
629
630impl FFFStringStorage for &FilePicker {
631    #[inline]
632    fn arena_for(&self, file: &FileItem) -> crate::simd_path::ArenaPtr {
633        self.sync_data.arena_for_file(file)
634    }
635
636    #[inline]
637    fn base_arena(&self) -> crate::simd_path::ArenaPtr {
638        self.sync_data.arena_base_ptr()
639    }
640
641    #[inline]
642    fn overflow_arena(&self) -> crate::simd_path::ArenaPtr {
643        self.sync_data.arena_overflow_ptr()
644    }
645}
646
647impl FilePicker {
648    pub fn base_path(&self) -> &Path {
649        &self.base_path
650    }
651
652    pub fn has_git_repo(&self) -> bool {
653        self.sync_data.git_workdir.is_some()
654    }
655
656    /// Ignore rules the walker assembled during the last scan (zlob backend
657    /// only). The background watcher uses these to filter events without
658    /// libgit2. `None` when the backend doesn't surface rules or no ignore
659    /// files were present.
660    pub(crate) fn ignore_rules(&self) -> Option<Arc<crate::walk::WalkIgnoreRules>> {
661        self.sync_data.ignore_rules.clone()
662    }
663
664    pub fn has_mmap_cache(&self) -> bool {
665        self.enable_mmap_cache
666    }
667
668    pub fn has_content_indexing(&self) -> bool {
669        self.enable_content_indexing
670    }
671
672    pub fn has_watcher(&self) -> bool {
673        self.watch
674    }
675
676    pub fn is_watcher_ready(&self) -> bool {
677        self.background_watcher.is_some() && self.signals.watcher_ready.load(Ordering::Acquire)
678    }
679
680    pub fn follows_symlinks(&self) -> bool {
681        self.follow_symlinks
682    }
683
684    pub fn fs_root_scanning_enabled(&self) -> bool {
685        self.enable_fs_root_scanning
686    }
687
688    pub fn home_dir_scanning_enabled(&self) -> bool {
689        self.enable_home_dir_scanning
690    }
691
692    pub fn git_recency_config(&self) -> GitRecencyConfig {
693        self.git_recency_config
694    }
695
696    pub fn trace_id(&self) -> &str {
697        &self.trace_id
698    }
699
700    pub fn trace_span(&self) -> tracing::Span {
701        self.trace_span.clone()
702    }
703
704    pub fn mode(&self) -> FFFMode {
705        self.mode
706    }
707
708    pub fn cache_budget(&self) -> &ContentCacheBudget {
709        &self.cache_budget
710    }
711
712    pub fn bigram_index(&self) -> Option<&BigramFilter> {
713        self.sync_data.bigram_index.as_deref()
714    }
715
716    pub fn bigram_overlay(&self) -> Option<&parking_lot::RwLock<BigramOverlay>> {
717        self.sync_data.bigram_overlay.as_deref()
718    }
719
720    pub fn get_file_mut(&mut self, index: usize) -> Option<(ArenaPtr, &mut FileItem)> {
721        self.sync_data.get_file_mut(index)
722    }
723
724    /// Absolute path to the repository root if the indexed tree lives
725    /// inside a git working directory. `None` for non-git bases.
726    pub fn git_root(&self) -> Option<&Path> {
727        self.sync_data.git_workdir.as_deref()
728    }
729
730    pub fn has_explicit_cache_budget(&self) -> bool {
731        self.has_explicit_cache_budget
732    }
733
734    pub fn set_cache_budget(&mut self, budget: ContentCacheBudget) {
735        self.cache_budget = Arc::new(budget);
736    }
737
738    /// Get all indexed files sorted by path.
739    /// Note: Files are stored sorted by PATH for efficient insert/remove.
740    /// For frecency-sorted results, use search() which sorts matched results.
741    pub fn get_files(&self) -> &[FileItem] {
742        self.sync_data.files()
743    }
744
745    /// Count of live (non-tombstoned) files. O(1).
746    #[inline]
747    pub fn live_file_count(&self) -> usize {
748        self.sync_data.live_count
749    }
750
751    pub fn get_overflow_files(&self) -> &[FileItem] {
752        self.sync_data.overflow_files()
753    }
754
755    /// Get the directory table (sorted by path).
756    pub fn get_dirs(&self) -> &[DirItem] {
757        &self.sync_data.dirs
758    }
759
760    /// Actual heap bytes used: (chunked_path_store, 0, 0).
761    /// The second element is 0 because leaked overflow stores aren't tracked.
762    pub fn arena_bytes(&self) -> (usize, usize, usize) {
763        let chunked = self
764            .sync_data
765            .chunked_paths
766            .as_ref()
767            .map_or(0, |s| s.heap_bytes());
768
769        (chunked, 0, 0)
770    }
771
772    #[tracing::instrument(level = "debug", skip_all)]
773    pub(crate) fn for_each_dir(&self, mut f: impl FnMut(&Path) -> ControlFlow<()>) {
774        let dir_table = &self.sync_data.dirs;
775        let base = self.base_path.as_path();
776
777        if !dir_table.is_empty() {
778            let arena = self.arena_base_ptr();
779            let overflow_arena = self.sync_data.arena_overflow_ptr();
780            let mut path_buf = PathBuf::with_capacity(crate::simd_path::PATH_BUF_SIZE);
781            let mut prev_relative_path = String::new();
782
783            let mut scratch_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
784            for dir_item in dir_table.iter() {
785                if dir_item.is_deleted() {
786                    continue;
787                }
788                let item_arena = if dir_item.is_overflow() {
789                    overflow_arena
790                } else {
791                    arena
792                };
793                let full_relative_path = dir_item.read_relative_path(item_arena, &mut scratch_buf);
794                let relative_path = full_relative_path.trim_end_matches(std::path::is_separator);
795
796                if relative_path.is_empty() {
797                    // Files directly under base_path
798                    prev_relative_path.clear();
799                    continue;
800                }
801
802                let mut i = common_dir_prefix_len(&prev_relative_path, relative_path);
803                // If we stopped on a separator, skip it — we want to start
804                // emitting at the first unseen segment, not re-emit the
805                // already-emitted prefix path.
806                if i < relative_path.len()
807                    && std::path::is_separator(relative_path.as_bytes()[i] as char)
808                {
809                    i += 1;
810                }
811
812                // Walk the suffix of `relative_path` one segment at a time, emitting
813                // each previously unseen ancestor up to and including `relative_path`.
814                while i < relative_path.len() {
815                    let next_sep = relative_path[i..]
816                        .find(std::path::is_separator)
817                        .map(|off| i + off)
818                        .unwrap_or(relative_path.len());
819                    let ancestor_rel = &relative_path[..next_sep];
820
821                    path_buf.clear();
822                    path_buf.push(base);
823                    path_buf.push(ancestor_rel);
824
825                    // we can't really emit iterator here unfortunately
826                    if matches!(f(path_buf.as_path()), ControlFlow::Break(())) {
827                        return;
828                    }
829
830                    i = next_sep + 1;
831                }
832
833                prev_relative_path.clear();
834                prev_relative_path.push_str(relative_path);
835            }
836            return;
837        }
838
839        // fallback that should never be happening, but it is possible to get the file
840        // path from the absolute path using components api as well:
841        let files = self.sync_data.files();
842        let arena = self.arena_base_ptr();
843        let mut current = self.base_path.clone();
844        let mut path_buf = [0u8; PATH_BUF_SIZE];
845
846        for file in files {
847            let abs = file.write_absolute_path(arena, base, &mut path_buf);
848            let Some(parent) = abs.parent() else {
849                continue;
850            };
851            if parent == current.as_path() {
852                continue;
853            }
854
855            while current.as_path() != base && !parent.starts_with(&current) {
856                current.pop();
857            }
858
859            let Ok(remainder) = parent.strip_prefix(&current) else {
860                continue;
861            };
862            for component in remainder.components() {
863                current.push(component);
864                if matches!(f(current.as_path()), ControlFlow::Break(())) {
865                    return;
866                }
867            }
868        }
869    }
870
871    /// Create a new FilePicker from options.
872    /// Always prefer new_with_shared_state for the consumer application, use this only if you know
873    /// what you are doing. This won't spawn the backgraound watcher and won't walk the file tree.
874    pub fn new(options: FilePickerOptions) -> Result<Self, Error> {
875        crate::git::tune_libgit2_for_local_reads();
876
877        let path = PathBuf::from(&options.base_path);
878        if !path.exists() {
879            error!("Base path does not exist: {}", options.base_path);
880            return Err(Error::InvalidPath(path));
881        }
882        // Relative bases (".", "sub/dir") are resolved against the cwd so
883        // they can be compared with the absolute paths reported by the OS
884        // watcher. Purely lexical: no symlinks are resolved. The
885        // `components()` pass drops interior `.` segments ("/cwd/.").
886        let path = if path.is_relative() {
887            std::env::current_dir()
888                .map(|cwd| cwd.join(&path).components().collect())
889                .unwrap_or(path)
890        } else {
891            path
892        };
893        if path.parent().is_none() && !options.enable_fs_root_scanning {
894            error!("Refusing to index filesystem root: {}", path.display());
895            return Err(Error::FilesystemRoot(path));
896        }
897        if !options.enable_home_dir_scanning
898            && Some(path.as_os_str()) == dirs::home_dir().as_ref().map(|p| p.as_os_str())
899        {
900            error!("Refusing to index home directory: {}", path.display());
901            return Err(Error::FilesystemRoot(path));
902        }
903
904        // Windows-only: canonicalize with dunce so the base path does NOT
905        // have the `\\?\` UNC prefix that `std::fs::canonicalize` adds.
906        // libgit2's `repo.workdir()`
907        #[cfg(windows)]
908        let path = crate::path_utils::canonicalize(&path).unwrap_or(path);
909
910        let has_explicit_budget = options.cache_budget.is_some();
911        let initial_budget = options.cache_budget.unwrap_or_default();
912
913        let trace_id = crate::log::generate_trace_id();
914        let trace_span = crate::log::trace_span(&trace_id, "picker");
915
916        Ok(FilePicker {
917            background_watcher: None,
918            git_status_worker: crate::git_status_worker::GitStatusWorker::new(),
919            base_path: path,
920            cache_budget: Arc::new(initial_budget),
921            has_explicit_cache_budget: has_explicit_budget,
922            signals: crate::scan::ScanSignals::default(),
923            mode: options.mode,
924            scanned_files_count: Arc::new(AtomicUsize::new(0)),
925            sync_data: FileSync::new(),
926            enable_mmap_cache: options.enable_mmap_cache,
927            enable_content_indexing: options.enable_content_indexing,
928            watch: options.watch,
929            follow_symlinks: options.follow_symlinks,
930            enable_fs_root_scanning: options.enable_fs_root_scanning,
931            enable_home_dir_scanning: options.enable_home_dir_scanning,
932            git_recency_config: options.git_recency,
933            trace_span,
934            trace_id,
935        })
936    }
937
938    /// Create a picker, place it into the shared handle, and spawn background
939    /// indexing + file-system watcgenerate_trace_id the default entry point.
940    pub fn new_with_shared_state(
941        shared_picker: SharedFilePicker,
942        shared_frecency: SharedFrecency,
943        options: FilePickerOptions,
944    ) -> Result<(), Error> {
945        let picker = Self::new(options)?;
946
947        info!(
948            "Spawning background threads: base_path={}, warmup={}, content_indexing={}, mode={:?}",
949            picker.base_path.display(),
950            picker.enable_mmap_cache,
951            picker.enable_content_indexing,
952            picker.mode,
953        );
954
955        let warmup = picker.enable_mmap_cache;
956        let content_indexing = picker.enable_content_indexing;
957        let watch = picker.watch;
958        let mode = picker.mode;
959        let follow_symlinks = picker.follow_symlinks;
960        let enable_fs_root_scanning = picker.enable_fs_root_scanning;
961        let enable_home_dir_scanning = picker.enable_home_dir_scanning;
962
963        let signals = picker.scan_signals();
964        let scanned_files_counter = picker.scanned_files_counter();
965        let path = picker.base_path.clone();
966        let trace_span = picker.trace_span.clone();
967
968        // Pre-arm `scanning` BEFORE publishing the new picker. `ScanJob::spawn`
969        // also sets it, but that runs after this function returns; consumers
970        // (e.g. lua `wait_for_initial_scan` after `restart_index_in_path`)
971        // that grab the signal Arc between publish and spawn would otherwise
972        // observe scanning=false and skip the wait, racing the walker. The
973        // race is wide on Windows CI where notify is slow.
974        signals
975            .scanning
976            .store(true, std::sync::atomic::Ordering::Release);
977
978        // Update the watch base before publishing the new picker.
979        shared_picker.rebase_watches(&path);
980
981        {
982            let mut guard = shared_picker.write()?;
983            *guard = Some(picker);
984            // dropping old picker flips its `cancelled` flag → bg threads exit cleanly
985        }
986
987        ScanJob::new_initial(
988            shared_picker,
989            shared_frecency,
990            path,
991            mode,
992            signals,
993            scanned_files_counter,
994            trace_span,
995            ScanConfig {
996                warmup,
997                content_indexing,
998                watch,
999                auto_cache_budget: true,
1000                install_watcher: true,
1001                follow_symlinks,
1002                enable_fs_root_scanning,
1003                enable_home_dir_scanning,
1004            },
1005        )
1006        .spawn();
1007
1008        Ok(())
1009    }
1010
1011    /// Synchronous filesystem scan — populates `self` with indexed files.
1012    ///
1013    /// Use this when you need direct access to the picker without shared state:
1014    /// ```ignore
1015    /// let mut picker = FilePicker::new(options)?;
1016    /// picker.collect_files()?;
1017    /// // picker.get_files() is now populated
1018    /// ```
1019    pub fn collect_files(&mut self) -> Result<(), Error> {
1020        self.signals.scanning.store(true, Ordering::Relaxed);
1021        self.scanned_files_count.store(0, Ordering::Relaxed);
1022
1023        let git_workdir = FileSync::discover_git_workdir(&self.base_path);
1024        let git_handle = git_workdir.clone().map(FileSync::spawn_git_status);
1025
1026        let empty_frecency = SharedFrecency::default();
1027        let sync = FileSync::walk_filesystem(
1028            &self.base_path,
1029            git_workdir,
1030            &self.scanned_files_count,
1031            &empty_frecency,
1032            self.mode,
1033            self.follow_symlinks,
1034        )?;
1035
1036        self.sync_data = sync;
1037
1038        if !self.has_explicit_cache_budget {
1039            let file_count = self.sync_data.files().len();
1040            self.cache_budget = Arc::new(ContentCacheBudget::new_for_repo(file_count));
1041        } else {
1042            self.cache_budget.reset();
1043        }
1044
1045        if let Some(handle) = git_handle
1046            && let Ok(Some(git_cache)) = handle.join()
1047        {
1048            let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
1049
1050            let arena = self.arena_base_ptr();
1051            for file in self.sync_data.files.iter_mut() {
1052                file.git_status = git_cache.lookup_status(file.write_absolute_path(
1053                    arena,
1054                    &self.base_path,
1055                    &mut path_buf,
1056                ));
1057            }
1058        }
1059
1060        if let Some(workdir) = self.sync_data.git_workdir.clone()
1061            && let Ok(repo) = Repository::open(&workdir)
1062                .inspect_err(|e| debug!(?e, ?workdir, "git recency: failed to open repo"))
1063        {
1064            let recency =
1065                git_recency::compute_git_recency(&repo, &self.git_recency_config, &self.base_path);
1066            self.apply_git_recency(recency.as_ref());
1067        }
1068
1069        self.signals.scanning.store(false, Ordering::Relaxed);
1070        Ok(())
1071    }
1072
1073    /// Perform fuzzy search on files with a pre-parsed query.
1074    ///
1075    /// The query should be parsed using [`crate::FFFQuery`] before calling
1076    /// this function. If a [`crate::QueryTracker`] is provided, the search will
1077    /// automatically look up the last selected file for this query and boost it
1078    #[tracing::instrument(skip_all, name = "Fuzzy file search", fields(query = query.raw_query))]
1079    pub fn fuzzy_search<'q>(
1080        &self,
1081        query: &'q FFFQuery<'q>,
1082        query_tracker: Option<&QueryTracker>,
1083        options: FuzzySearchOptions<'q>,
1084    ) -> SearchResult<'_> {
1085        let files = self.get_files();
1086        let max_threads = if options.max_threads == 0 {
1087            std::thread::available_parallelism()
1088                .map(|n| n.get())
1089                .unwrap_or(4)
1090        } else {
1091            options.max_threads
1092        };
1093
1094        debug!(
1095            raw_query = ?query.raw_query,
1096            pagination = ?options.pagination,
1097            ?max_threads,
1098            current_file = ?options.current_file,
1099            "Fuzzy search",
1100        );
1101
1102        let total_files = self.live_file_count();
1103        let location = query.location;
1104
1105        // Get effective query for max_typos calculation (without location suffix)
1106        let effective_query = match &query.fuzzy_query {
1107            fff_query_parser::FuzzyQuery::Text(t) => *t,
1108            fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
1109            _ => query.raw_query.trim(),
1110        };
1111
1112        // small queries with a large number of results can match absolutely everything
1113        let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
1114        // Look up the last file selected for this query (combo-boost scoring)
1115        let last_same_query_entry =
1116            query_tracker
1117                .zip(options.project_path)
1118                .and_then(|(tracker, project_path)| {
1119                    tracker
1120                        .get_last_query_entry(
1121                            query.raw_query,
1122                            project_path,
1123                            options.min_combo_count,
1124                        )
1125                        .ok()
1126                        .flatten()
1127                });
1128
1129        let context = ScoringContext {
1130            query,
1131            max_typos,
1132            max_threads,
1133            project_path: options.project_path,
1134            current_file: options.current_file,
1135            last_same_query_match: last_same_query_entry,
1136            combo_boost_score_multiplier: options.combo_boost_score_multiplier,
1137            min_combo_count: options.min_combo_count,
1138            pagination: options.pagination,
1139        };
1140
1141        let time = std::time::Instant::now();
1142
1143        let base_arena = self.sync_data.arena_base_ptr();
1144        let overflow_arena = self.sync_data.arena_overflow_ptr();
1145
1146        let (items, scores, total_matched) = fuzzy_match_and_score_files(
1147            files,
1148            &context,
1149            self.sync_data.base_count,
1150            base_arena,
1151            overflow_arena,
1152        );
1153        let match_byte_offsets =
1154            fuzzy_match_byte_offsets_for_page(query, &items, max_typos, base_arena, overflow_arena);
1155
1156        info!(
1157            ?query,
1158            completed_in = ?time.elapsed(),
1159            total_matched,
1160            returned_count = items.len(),
1161            pagination = ?options.pagination,
1162            "Fuzzy search completed",
1163        );
1164
1165        SearchResult {
1166            items,
1167            scores,
1168            match_byte_offsets,
1169            total_matched,
1170            total_files,
1171            location,
1172        }
1173    }
1174
1175    /// Perform fuzzy search on indexed directories.
1176    ///
1177    /// Returns directories ranked by fuzzy match quality + frecency.
1178    pub fn fuzzy_search_directories<'q>(
1179        &self,
1180        query: &'q FFFQuery<'q>,
1181        options: FuzzySearchOptions<'q>,
1182    ) -> DirSearchResult<'_> {
1183        let dirs = self.get_dirs();
1184        let max_threads = if options.max_threads == 0 {
1185            std::thread::available_parallelism()
1186                .map(|n| n.get())
1187                .unwrap_or(4)
1188        } else {
1189            options.max_threads
1190        };
1191
1192        let total_dirs = self.sync_data.live_dirs_count;
1193
1194        let effective_query = match &query.fuzzy_query {
1195            fff_query_parser::FuzzyQuery::Text(t) => *t,
1196            fff_query_parser::FuzzyQuery::Parts(parts) if !parts.is_empty() => parts[0],
1197            _ => query.raw_query.trim(),
1198        };
1199
1200        let max_typos = (effective_query.len() as u16 / 4).clamp(2, 6);
1201
1202        let context = ScoringContext {
1203            query,
1204            max_typos,
1205            max_threads,
1206            project_path: options.project_path,
1207            current_file: options.current_file,
1208            last_same_query_match: None,
1209            combo_boost_score_multiplier: 0,
1210            min_combo_count: 0,
1211            pagination: options.pagination,
1212        };
1213
1214        let arena = self.sync_data.arena_base_ptr();
1215        let overflow_arena = self.sync_data.arena_overflow_ptr();
1216        let time = std::time::Instant::now();
1217
1218        let (items, scores, total_matched) =
1219            crate::score::fuzzy_match_and_score_dirs(dirs, &context, arena, overflow_arena);
1220
1221        info!(
1222            ?query,
1223            completed_in = ?time.elapsed(),
1224            total_matched,
1225            returned_count = items.len(),
1226            "Directory search completed",
1227        );
1228
1229        DirSearchResult {
1230            items,
1231            scores,
1232            total_matched,
1233            total_dirs,
1234        }
1235    }
1236
1237    /// Perform a mixed fuzzy search across both files and directories.
1238    ///
1239    /// Returns a single flat list where files and directories are interleaved
1240    /// by total score in descending order.
1241    ///
1242    /// If the raw query ends with a path separator (`/`), only directories
1243    /// are searched — files are skipped entirely. The caller should parse the
1244    /// query with `DirSearchConfig` so that trailing `/` is kept as fuzzy
1245    /// text instead of becoming a `PathSegment` constraint.
1246    pub fn fuzzy_search_mixed<'q>(
1247        &self,
1248        query: &'q FFFQuery<'q>,
1249        query_tracker: Option<&QueryTracker>,
1250        options: FuzzySearchOptions<'q>,
1251    ) -> MixedSearchResult<'_> {
1252        let location = query.location;
1253        let page_offset = options.pagination.offset;
1254        let page_limit = if options.pagination.limit > 0 {
1255            options.pagination.limit
1256        } else {
1257            100
1258        };
1259
1260        let dirs_only =
1261            query.raw_query.ends_with(std::path::MAIN_SEPARATOR) || query.raw_query.ends_with('/');
1262
1263        // Run file search and dir search with no pagination (we merge then paginate).
1264        let internal_limit = page_offset.saturating_add(page_limit).saturating_mul(2);
1265
1266        let dir_options = FuzzySearchOptions {
1267            pagination: PaginationArgs {
1268                offset: 0,
1269                limit: internal_limit,
1270            },
1271            ..options
1272        };
1273        let dir_results = self.fuzzy_search_directories(query, dir_options);
1274
1275        if dirs_only {
1276            let total_matched = dir_results.total_matched;
1277            let total_dirs = dir_results.total_dirs;
1278
1279            let mut merged: Vec<(MixedItemRef<'_>, Score)> =
1280                Vec::with_capacity(dir_results.items.len());
1281            for (dir, score) in dir_results.items.into_iter().zip(dir_results.scores) {
1282                merged.push((MixedItemRef::Dir(dir), score));
1283            }
1284
1285            if page_offset >= merged.len() {
1286                return MixedSearchResult {
1287                    items: vec![],
1288                    scores: vec![],
1289                    total_matched,
1290                    total_files: self.live_file_count(),
1291                    total_dirs,
1292                    location,
1293                };
1294            }
1295
1296            let end = (page_offset + page_limit).min(merged.len());
1297            let page = merged.drain(page_offset..end);
1298            let (items, scores): (Vec<_>, Vec<_>) = page.unzip();
1299
1300            return MixedSearchResult {
1301                items,
1302                scores,
1303                total_matched,
1304                total_files: self.live_file_count(),
1305                total_dirs,
1306                location,
1307            };
1308        }
1309
1310        let file_options = FuzzySearchOptions {
1311            pagination: PaginationArgs {
1312                offset: 0,
1313                limit: internal_limit,
1314            },
1315            ..options
1316        };
1317        let file_results = self.fuzzy_search(query, query_tracker, file_options);
1318
1319        // Merge by score descending.
1320        let total_matched = file_results.total_matched + dir_results.total_matched;
1321        let total_files = file_results.total_files;
1322        let total_dirs = dir_results.total_dirs;
1323
1324        let mut merged: Vec<(MixedItemRef<'_>, Score)> =
1325            Vec::with_capacity(file_results.items.len() + dir_results.items.len());
1326
1327        for (file, score) in file_results.items.into_iter().zip(file_results.scores) {
1328            merged.push((MixedItemRef::File(file), score));
1329        }
1330        for (dir, score) in dir_results.items.into_iter().zip(dir_results.scores) {
1331            merged.push((MixedItemRef::Dir(dir), score));
1332        }
1333
1334        // Sort merged results by total score descending.
1335        merged.sort_unstable_by_key(|b| std::cmp::Reverse(b.1.total));
1336
1337        // Paginate.
1338        if page_offset >= merged.len() {
1339            return MixedSearchResult {
1340                items: vec![],
1341                scores: vec![],
1342                total_matched,
1343                total_files,
1344                total_dirs,
1345                location,
1346            };
1347        }
1348
1349        let end = (page_offset + page_limit).min(merged.len());
1350        let page = merged.drain(page_offset..end);
1351        let (items, scores): (Vec<_>, Vec<_>) = page.unzip();
1352
1353        MixedSearchResult {
1354            items,
1355            scores,
1356            total_matched,
1357            total_files,
1358            total_dirs,
1359            location,
1360        }
1361    }
1362
1363    /// Glob search: filter indexed files by a single glob pattern, rank by
1364    /// frecency, and paginate. Bypasses the regular query parser entirely —
1365    /// useful when callers already have a literal glob (`*.rs`, `**/*.test.ts`)
1366    /// and want neither fuzzy matching nor multi-token constraint parsing.
1367    ///
1368    /// Pipeline: `apply_constraints(Glob) → score_filtered_by_frecency → sort_and_paginate`.
1369    /// Same ranking semantics as `fuzzy_search` when the fuzzy query is empty.
1370    pub fn glob<'p>(
1371        &'p self,
1372        pattern: &'p str,
1373        options: FuzzySearchOptions<'p>,
1374    ) -> SearchResult<'p> {
1375        let query = FFFQuery {
1376            raw_query: pattern,
1377            constraints: vec![fff_query_parser::Constraint::Glob(pattern)],
1378            fuzzy_query: fff_query_parser::FuzzyQuery::Empty,
1379            location: None,
1380        };
1381
1382        // `fuzzy_search` short-circuits to `score_filtered_by_frecency` when
1383        // `fuzzy_query` is `Empty`, then runs the same `sort_and_paginate`
1384        // path. Reusing it keeps the ranking guarantees identical without
1385        // exposing the private scoring helpers.
1386        self.fuzzy_search(&query, None, options)
1387    }
1388
1389    /// Perform a live grep search across indexed files.
1390    ///
1391    /// If `options.abort_signal` is set it overrides the picker's internal
1392    /// cancellation flag, giving the caller full control over when to stop.
1393    pub fn grep(&self, query: &FFFQuery<'_>, options: &GrepSearchOptions) -> GrepResult<'_> {
1394        let overlay_guard = self.sync_data.bigram_overlay.as_ref().map(|o| o.read());
1395        let arena = self.arena_base_ptr();
1396        let overflow_arena = self.sync_data.arena_overflow_ptr();
1397        let cancel = options
1398            .abort_signal
1399            .as_deref()
1400            .unwrap_or(&self.signals.cancelled);
1401
1402        SEARCH_THREAD_POOL.install(|| {
1403            grep_search(
1404                self.get_files(),
1405                query,
1406                options,
1407                self.cache_budget(),
1408                self.sync_data.bigram_index.as_deref(),
1409                overlay_guard.as_deref(),
1410                cancel,
1411                &self.base_path,
1412                arena,
1413                overflow_arena,
1414            )
1415        })
1416    }
1417
1418    /// Multi-pattern grep search across indexed files.
1419    pub fn multi_grep(
1420        &self,
1421        patterns: &[&str],
1422        constraints: &[fff_query_parser::Constraint<'_>],
1423        options: &GrepSearchOptions,
1424    ) -> GrepResult<'_> {
1425        let overlay_guard = self.sync_data.bigram_overlay.as_ref().map(|o| o.read());
1426        let arena = self.arena_base_ptr();
1427        let overflow_arena = self.sync_data.arena_overflow_ptr();
1428        let cancel = options
1429            .abort_signal
1430            .as_deref()
1431            .unwrap_or(&self.signals.cancelled);
1432
1433        SEARCH_THREAD_POOL.install(|| {
1434            multi_grep_search(
1435                self.get_files(),
1436                patterns,
1437                constraints,
1438                options,
1439                self.cache_budget(),
1440                self.sync_data.bigram_index.as_deref(),
1441                overlay_guard.as_deref(),
1442                cancel,
1443                &self.base_path,
1444                arena,
1445                overflow_arena,
1446            )
1447        })
1448    }
1449
1450    // Returns an ongoing or finisshed scan progress
1451    pub fn get_scan_progress(&self) -> ScanProgress {
1452        let scanned_count = self.scanned_files_count.load(Ordering::Relaxed);
1453        let is_scanning = self.signals.scanning.load(Ordering::Relaxed);
1454
1455        ScanProgress {
1456            scanned_files_count: scanned_count,
1457            is_scanning,
1458            is_watcher_ready: self.signals.watcher_ready.load(Ordering::Relaxed),
1459            is_warmup_complete: !self.enable_content_indexing
1460                || self.sync_data.bigram_index.is_some(),
1461        }
1462    }
1463
1464    pub(crate) fn set_bigram_index(&mut self, index: BigramFilter) {
1465        self.sync_data.bigram_index = Some(Arc::new(index));
1466        // once the index is reset automatically reset the overaly
1467        self.sync_data.bigram_overlay = Some(Arc::new(parking_lot::RwLock::new(
1468            BigramOverlay::new(self.sync_data.indexable_count),
1469        )));
1470    }
1471
1472    pub(crate) fn scan_signals(&self) -> crate::scan::ScanSignals {
1473        self.signals.clone()
1474    }
1475
1476    pub(crate) fn scanned_files_counter(&self) -> Arc<AtomicUsize> {
1477        Arc::clone(&self.scanned_files_count)
1478    }
1479
1480    /// Capture raw pointers to the picker's internal arrays for off-lock use.
1481    ///
1482    /// Sets `post_scan_indexing_active` and returns a snapshot that clears it
1483    /// on drop. This is the ONLY approved way to escape the lock for
1484    /// long-running parallel work (git status, warmup, bigram).
1485    ///
1486    /// Returns `None` if `post_scan_indexing_active` is already set — this
1487    /// means another post-scan is in flight and we must not create a second
1488    /// set of dangling pointers.
1489    ///
1490    /// # Safety
1491    /// 1. `walk_filesystem` reserved `MAX_OVERFLOW_FILES` capacity on the
1492    ///    files Vec at creation — watcher pushes cannot reallocate it.
1493    /// 2. `post_scan_indexing_active` is set — prevents `commit_new_sync`
1494    ///    from replacing the Vec (ScanJob::new checks this flag).
1495    /// 3. Only `[..base_count]` is accessed — base files use the immutable
1496    ///    base arena. Overflow files use a different arena.
1497    pub(crate) unsafe fn post_scan_snapshot(&self) -> Option<PostScanUnsafeSnapshot> {
1498        if self
1499            .signals
1500            .post_scan_indexing_active
1501            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
1502            .is_err()
1503        {
1504            tracing::error!(
1505                "Can not acquire post scan unsafe snapshot, someone already acquired it"
1506            );
1507            return None;
1508        }
1509
1510        Some(PostScanUnsafeSnapshot {
1511            files: self.sync_data.files.clone(),
1512            arena: self.sync_data.chunked_paths.as_ref().map(Arc::clone),
1513            base_count: self.sync_data.base_count,
1514            indexable_count: self.sync_data.indexable_count,
1515            base_path: self.base_path.clone(),
1516            post_scan_flag: Arc::clone(&self.signals.post_scan_indexing_active),
1517            _budget: Arc::clone(&self.cache_budget),
1518        })
1519    }
1520
1521    pub(crate) fn commit_new_sync(&mut self, sync: FileSync) {
1522        self.sync_data = sync;
1523        self.cache_budget.reset();
1524    }
1525
1526    #[inline]
1527    pub(crate) fn arena_base_ptr(&self) -> ArenaPtr {
1528        self.sync_data.arena_base_ptr()
1529    }
1530
1531    /// Update git statuses for files, using the provided shared frecency tracker.
1532    pub(crate) fn update_git_statuses(
1533        &mut self,
1534        status_cache: GitStatusCache,
1535        shared_frecency: &SharedFrecency,
1536    ) -> Result<(), Error> {
1537        debug!(
1538            statuses_count = status_cache.statuses_len(),
1539            "Updating git status",
1540        );
1541
1542        let mode = self.mode;
1543        let bp = self.base_path.clone();
1544        let frecency = shared_frecency.read()?;
1545
1546        status_cache
1547            .into_iter()
1548            .try_for_each(|(path, status)| -> Result<(), Error> {
1549                if let Some((arena, file)) = self.get_mut_file_by_path(&path) {
1550                    file.git_status = Some(status);
1551                    if let Some(ref f) = *frecency {
1552                        file.update_frecency_scores(f, arena, &bp, mode)?;
1553                    }
1554                    // Update parent dir frecency inline. `DirItem` has an
1555                    // interior-mutable atomic score, so `&self` access is
1556                    // enough — no write aliasing against Arc clones.
1557                    let score = file.access_frecency_score as i32;
1558                    let dir_idx = file.parent_dir_index as usize;
1559                    if let Some(dir) = self.sync_data.dirs.get(dir_idx) {
1560                        dir.update_frecency_if_larger(score);
1561                    }
1562                } else {
1563                    // Expected on sparse checkouts: git reports a status for
1564                    // a path that isn't materialized on disk and therefore
1565                    // isn't in the file index. Don't spam the log (#404).
1566                    debug!(?path, "Git status for path not in index, skipping");
1567                }
1568                Ok(())
1569            })?;
1570
1571        Ok(())
1572    }
1573
1574    // Replaces every recency score with a freshly computed set. `None` zeroes
1575    // them, so a vanished window (orphan HEAD, repo gone) leaves no stale boost.
1576    pub(crate) fn apply_git_recency(&mut self, scores: Option<&AHashMap<String, i16>>) {
1577        if !self.git_recency_config.enabled {
1578            return;
1579        }
1580
1581        for file in self.sync_data.files.iter_mut() {
1582            file.git_recency_score = 0;
1583        }
1584
1585        let Some(scores) = scores else { return };
1586
1587        let mut applied = 0usize;
1588        for (relative_path, score) in scores {
1589            if let Some(index) = self.sync_data.find_by_relative_path(relative_path)
1590                && let Some((_, file)) = self.sync_data.get_file_mut(index)
1591            {
1592                file.git_recency_score = *score;
1593                applied += 1;
1594            }
1595        }
1596
1597        debug!(files_scored = applied, "Git recency scores applied");
1598    }
1599
1600    pub fn update_single_file_frecency(
1601        &mut self,
1602        file_path: impl AsRef<Path>,
1603        frecency_tracker: &FrecencyTracker,
1604    ) -> Result<(), Error> {
1605        let path = file_path.as_ref();
1606
1607        let Some(index) = self.sync_data.find_file_index(path, &self.base_path) else {
1608            return Ok(());
1609        };
1610
1611        if let Some((arena, file)) = self.sync_data.get_file_mut(index) {
1612            file.update_frecency_scores(frecency_tracker, arena, &self.base_path, self.mode)?;
1613
1614            // Update parent dir frecency inline (atomic, &self access).
1615            let score = file.access_frecency_score as i32;
1616            let dir_idx = file.parent_dir_index as usize;
1617            if let Some(dir) = self.sync_data.dirs.get(dir_idx) {
1618                dir.update_frecency_if_larger(score);
1619            }
1620        }
1621
1622        Ok(())
1623    }
1624
1625    pub fn get_file_by_path(&self, path: impl AsRef<Path>) -> Option<&FileItem> {
1626        self.sync_data
1627            .find_file_index(path.as_ref(), &self.base_path)
1628            .and_then(|index| self.sync_data.files().get(index))
1629    }
1630
1631    pub fn get_mut_file_by_path(
1632        &mut self,
1633        path: impl AsRef<Path>,
1634    ) -> Option<(ArenaPtr, &mut FileItem)> {
1635        let path = path.as_ref();
1636        let index = self.sync_data.find_file_index(path, &self.base_path);
1637        index.and_then(|i| self.sync_data.get_file_mut(i))
1638    }
1639
1640    /// Handle the event of certain file being modified or adds a neww file if it is not added
1641    /// If this function returns `None` it means that picker is in the invalid state, or the capacity
1642    /// of index is exhausted and a new rescan needs to be triggered.
1643    #[tracing::instrument(skip(self),level = Level::DEBUG)]
1644    pub fn handle_create_or_modify(&mut self, path: impl AsRef<Path> + Debug) -> Option<&FileItem> {
1645        let path = path.as_ref();
1646
1647        if let Some(idx) = self.sync_data.find_file_index(path, &self.base_path) {
1648            let slot = if idx < self.sync_data.base_count {
1649                FileSlot::Base(idx)
1650            } else {
1651                FileSlot::Overflow(idx)
1652            };
1653
1654            return self.handle_file_modify(path, slot);
1655        }
1656
1657        self.add_new_file(path)
1658    }
1659
1660    #[tracing::instrument(skip_all, fields(path = ?path), level = Level::DEBUG)]
1661    fn handle_file_modify(&mut self, path: &Path, slot: FileSlot) -> Option<&FileItem> {
1662        let overlay = self.sync_data.bigram_overlay.as_ref().map(Arc::clone);
1663        let pos = slot.index();
1664
1665        // this is the only way to actually know if the file is on disk, we CAN NOT
1666        // rely on the watcher to proive the latest state of the file, do the actual check
1667        let metadata = match std::fs::metadata(path) {
1668            Ok(m) => {
1669                self.untombstone_file(pos);
1670
1671                m
1672            }
1673            Err(_) => {
1674                self.tombstone_file(pos);
1675                return None;
1676            }
1677        };
1678
1679        let (_arena, file) = self.sync_data.get_file_mut(pos)?;
1680
1681        let size = metadata.len();
1682        let modified_time = metadata
1683            .modified()
1684            .ok()
1685            .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1686            .map(|d| d.as_secs());
1687
1688        file.update_metadata(&self.cache_budget, modified_time, Some(size));
1689
1690        // Re-classify binary status from current content (chunked, fixed
1691        // buffer). Already-binary files are left alone.
1692        if !file.is_binary() {
1693            let mut chunk = [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE];
1694            file.detect_binary_per_byte(path, &mut chunk);
1695        }
1696
1697        // Indexable base-region files feed fresh content to the bigram overlay.
1698        if matches!(slot, FileSlot::Base(_))
1699            && let Some(ref overlay) = overlay
1700        {
1701            let in_indexable = {
1702                let guard = overlay.read();
1703                pos < guard.base_file_count()
1704            };
1705
1706            if in_indexable && let Ok(content) = std::fs::read(path) {
1707                overlay.write().modify_file(pos, &content);
1708            }
1709        }
1710
1711        self.sync_data.files().get(pos)
1712    }
1713
1714    /// Adds a new file to picker, if the file can not be added returns `None`
1715    /// which indicates that it's time to trigger a new sync
1716    #[tracing::instrument(skip(self))]
1717    pub fn add_new_file(&mut self, path: &Path) -> Option<&FileItem> {
1718        // On Windows `pathdiff::diff_paths` is byte-wise, so a short-name
1719        // input never shares a prefix with the canonicalized base_path and
1720        // the resulting relative path becomes absolute. Canonicalize first.
1721        #[cfg(windows)]
1722        let canonical_buf: Option<PathBuf> = if path.starts_with(&self.base_path) {
1723            None
1724        } else if let Ok(c) = crate::path_utils::canonicalize(path) {
1725            Some(c)
1726        } else {
1727            tracing::error!(path = ?path.display(), "Failed to canonicalize file path to add");
1728            return None;
1729        };
1730
1731        #[cfg(windows)]
1732        let path_for_index: &Path = canonical_buf.as_deref().unwrap_or(path);
1733        #[cfg(not(windows))]
1734        let path_for_index: &Path = path;
1735
1736        let (mut file_item, rel_path) =
1737            FileItem::new(path_for_index.to_path_buf(), &self.base_path, None);
1738
1739        // we have to perform manual classification for every new file this will be
1740        // batched during the scan, this is the path when the file is ad-hoc added to the sync
1741        file_item.detect_binary_per_byte(
1742            path_for_index,
1743            // inline chunk buf
1744            &mut [0u8; crate::types::BINARY_CLASSIFICATION_CHUNK_SIZE],
1745        );
1746
1747        let builder = self.sync_data.overflow_builder.get_or_insert_with(|| {
1748            // we know that overflow would never create more files during the file
1749            crate::simd_path::ChunkedPathStoreBuilder::new(MAX_OVERFLOW_FILES)
1750        });
1751
1752        file_item.set_path(builder.add_file_immediate(&rel_path, file_item.path.filename_offset));
1753        file_item.set_overflow(true);
1754
1755        // Keep the dir table consistent: register (or revive) the parent dir
1756        // so directory search reflects watcher-added files immediately.
1757        let dir_rel = crate::path_utils::to_canonical_slashes(
1758            &rel_path[..file_item.path.filename_offset as usize],
1759        );
1760
1761        if let Some(dir_idx) = self.sync_data.find_or_add_dir(&dir_rel) {
1762            file_item.parent_dir_index = dir_idx;
1763        }
1764        let parent_dir = file_item.parent_dir_index;
1765
1766        if !self.sync_data.files.push(file_item) {
1767            return None;
1768        }
1769
1770        self.sync_data.live_count += 1;
1771        // Dir may have been tombstoned by an earlier removal; a new file
1772        // under it proves it exists again.
1773        self.sync_data.revive_dir(parent_dir);
1774        self.sync_data.files.last()
1775    }
1776
1777    fn tombstone_file(&mut self, index: usize) {
1778        let file = &mut self.sync_data.files[index];
1779        if file.is_deleted() {
1780            return;
1781        }
1782
1783        file.set_deleted(true);
1784        file.invalidate_mmap(&self.cache_budget);
1785        file.git_status = None;
1786
1787        // Only base-region files participate in the bigram overlay
1788        if index < self.sync_data.base_count
1789            && let Some(ref overlay) = self.sync_data.bigram_overlay
1790        {
1791            overlay.write().delete_file(index);
1792        }
1793
1794        self.sync_data.live_count -= 1;
1795    }
1796
1797    fn untombstone_file(&mut self, index: usize) {
1798        let file = &mut self.sync_data.files[index];
1799        if !file.is_deleted() {
1800            return;
1801        }
1802        file.set_deleted(false);
1803        let parent_dir = file.parent_dir_index;
1804
1805        self.sync_data.live_count += 1;
1806        // The path exists on disk again, so its parent dir does too.
1807        self.sync_data.revive_dir(parent_dir);
1808    }
1809
1810    /// Marks file as deleted, make sure that if you call this yourself these changes can be reverted
1811    /// by the internal mechanics if the file actually exists on the disk, use only if you know that
1812    /// the file going to be disapperaed or if you do not have the watcher installed
1813    pub fn remove_file_by_path(&mut self, path: impl AsRef<Path>) -> bool {
1814        let path = path.as_ref();
1815        if let Some(index) = self.sync_data.find_file_index(path, &self.base_path) {
1816            self.tombstone_file(index);
1817            true
1818        } else {
1819            false
1820        }
1821    }
1822
1823    // TODO make this O(n)
1824    pub fn remove_all_files_in_dir(&mut self, dir: impl AsRef<Path>) -> usize {
1825        self.remove_all_files_in_dirs_inner(std::iter::once(dir.as_ref()), None)
1826    }
1827
1828    /// Tombstones files under any of `dirs` in a single index scan.
1829    pub(crate) fn remove_all_files_in_dirs_with_callback<'a>(
1830        &mut self,
1831        dirs: impl IntoIterator<Item = &'a Path>,
1832        mut callback: impl FnMut(&Path),
1833    ) -> usize {
1834        self.remove_all_files_in_dirs_inner(dirs, Some(&mut callback))
1835    }
1836
1837    pub(crate) fn remove_all_files_in_dirs<'a>(
1838        &mut self,
1839        dirs: impl IntoIterator<Item = &'a Path>,
1840    ) -> usize {
1841        self.remove_all_files_in_dirs_inner(dirs, None)
1842    }
1843
1844    fn remove_all_files_in_dirs_inner<'a>(
1845        &mut self,
1846        dirs: impl IntoIterator<Item = &'a Path>,
1847        mut callback: Option<&mut dyn FnMut(&Path)>,
1848    ) -> usize {
1849        let mut dir_prefixes = Vec::new();
1850        for dir_path in dirs {
1851            let Some(relative_dir) = self
1852                .to_relative_path(dir_path)
1853                .map(|path| path.into_owned())
1854            else {
1855                continue;
1856            };
1857
1858            if relative_dir.is_empty() {
1859                dir_prefixes.push(String::new());
1860            } else {
1861                // Stored relative paths are '/'-canonical on every platform.
1862                dir_prefixes.push(format!("{relative_dir}/"));
1863            }
1864        }
1865
1866        if dir_prefixes.is_empty() {
1867            return 0;
1868        }
1869
1870        let base_path = self.base_path.clone();
1871        let cache_budget = &self.cache_budget;
1872        let mut path_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
1873        let tombstoned = self.sync_data.tombstone_files_with_arena(
1874            |file, arena| {
1875                dir_prefixes
1876                    .iter()
1877                    .any(|prefix| file.relative_path_starts_with(arena, prefix))
1878            },
1879            |file, arena| {
1880                file.invalidate_mmap(cache_budget);
1881                if let Some(callback) = callback.as_mut() {
1882                    callback(file.write_absolute_path(arena, &base_path, &mut path_buf));
1883                }
1884            },
1885        );
1886
1887        // The whole subtree is gone: tombstone the dirs too so directory
1888        // search stops surfacing them.
1889        let mut dir_buf = [0u8; crate::simd_path::PATH_BUF_SIZE];
1890        self.sync_data.tombstone_dirs_with_arena(|dir, arena| {
1891            let rel = dir.read_relative_path(arena, &mut dir_buf);
1892            dir_prefixes.iter().any(|prefix| rel.starts_with(prefix))
1893        });
1894
1895        tombstoned
1896    }
1897
1898    /// Use this to prevent any substantial background threads from acquiring the locks
1899    pub fn cancel(&self) {
1900        self.signals.cancelled.store(true, Ordering::Release);
1901    }
1902
1903    /// Stop the background filesystem watcher. Non-blocking.
1904    pub fn stop_background_monitor(&mut self) {
1905        if let Some(mut watcher) = self.background_watcher.take() {
1906            watcher.stop();
1907        }
1908        self.signals.watcher_ready.store(false, Ordering::Release);
1909    }
1910
1911    /// Quick way to check if scan is going without acquiring a lock for [Self::get_scan_progress]
1912    pub fn is_scan_active(&self) -> bool {
1913        self.signals.scanning.load(Ordering::Relaxed)
1914    }
1915
1916    pub fn is_post_scan_active(&self) -> bool {
1917        self.signals
1918            .post_scan_indexing_active
1919            .load(Ordering::Acquire)
1920    }
1921
1922    /// Return a clone of the watcher-ready flag so callers can poll it without
1923    /// holding a lock on the picker.
1924    pub fn watcher_signal(&self) -> Arc<AtomicBool> {
1925        Arc::clone(&self.signals.watcher_ready)
1926    }
1927
1928    /// Convert an absolute path to a relative path string (relative to base_path).
1929    /// Returns None if the path doesn't start with base_path.
1930    ///
1931    /// On Windows the picker canonicalizes its base via `dunce`, so caller
1932    /// paths that still carry 8.3 short names or a different casing would
1933    /// fail a naive prefix check. Fall back to canonicalizing (or, when the
1934    /// file was just deleted, canonicalizing its parent) before stripping.
1935    fn to_relative_path<'a>(&self, path: &'a Path) -> Option<std::borrow::Cow<'a, str>> {
1936        if let Ok(stripped) = path.strip_prefix(&self.base_path)
1937            && let Some(s) = stripped.to_str()
1938        {
1939            // Callers compare against '/'-canonical stored paths.
1940            return Some(crate::path_utils::to_canonical_slashes(s));
1941        }
1942
1943        #[cfg(windows)]
1944        {
1945            let rel = canonical_relative_path(path, &self.base_path)?;
1946            return Some(std::borrow::Cow::Owned(rel));
1947        }
1948
1949        #[cfg(not(windows))]
1950        None
1951    }
1952}
1953
1954/// Resolve a possibly-short-name Windows path to the picker's canonical base.
1955/// Used by the Windows-only fallbacks in `to_relative_path` and
1956/// `find_file_index` so events still match tombstoned entries.
1957#[cfg(windows)]
1958fn canonical_relative_path(path: &Path, base: &Path) -> Option<String> {
1959    if let Ok(canonical) = crate::path_utils::canonicalize(path)
1960        && let Ok(stripped) = canonical.strip_prefix(base)
1961        && let Some(s) = stripped.to_str()
1962    {
1963        return Some(crate::path_utils::to_canonical_slashes(s).into_owned());
1964    }
1965
1966    // Deleted files can't be canonicalized — canonicalize the parent and
1967    // re-attach the filename.
1968    let parent = path.parent()?;
1969    let file_name = path.file_name()?;
1970    let canonical_parent = crate::path_utils::canonicalize(parent).ok()?;
1971    let stripped_parent = canonical_parent.strip_prefix(base).ok()?;
1972    let mut rel = stripped_parent.to_path_buf();
1973    rel.push(file_name);
1974    rel.to_str()
1975        .map(|s| crate::path_utils::to_canonical_slashes(s).into_owned())
1976}
1977
1978impl Drop for FilePicker {
1979    fn drop(&mut self) {
1980        // Cancel any in-flight ScanJob bound to this picker's signals so
1981        // it cannot mutate the replacement picker after a swap.
1982        self.signals.cancelled.store(true, Ordering::Release);
1983        // Wake the git-status consumer so it exits; never joined (it takes
1984        // the picker write lock, a blocking join here could deadlock).
1985        self.git_status_worker.signal_shutdown();
1986    }
1987}
1988
1989#[derive(Debug, Clone, Copy)]
1990enum FileSlot {
1991    Base(usize),
1992    Overflow(usize),
1993}
1994
1995impl FileSlot {
1996    fn index(self) -> usize {
1997        match self {
1998            FileSlot::Base(i) | FileSlot::Overflow(i) => i,
1999        }
2000    }
2001}
2002
2003/// Snapshot of FilePicker state for off-lock post-scan work.
2004///
2005/// Each data field is an Arc-shared clone of the picker's backing
2006/// allocation, so dropping the `FilePicker` (e.g. via
2007/// `SharedFilePicker::write().take()`) cannot free memory this
2008/// snapshot is still reading — UAF is impossible by construction.
2009///
2010/// Implements `Drop` to clear `post_scan_indexing_active`. Since only
2011/// one snapshot can exist at a time (enforced by the flag check in
2012/// `post_scan_snapshot`) and it is always created/dropped within
2013/// `ScanJob::run`, `scan_job_running == false` implies no live snapshot.
2014pub(crate) struct PostScanUnsafeSnapshot {
2015    pub files: StableVec<FileItem>,
2016    pub arena: Option<Arc<crate::simd_path::ChunkedPathStore>>,
2017    // TODO figure this out
2018    pub _budget: Arc<crate::types::ContentCacheBudget>,
2019    pub base_count: usize,
2020    pub indexable_count: usize,
2021    pub base_path: PathBuf,
2022    post_scan_flag: Arc<AtomicBool>,
2023}
2024
2025impl Drop for PostScanUnsafeSnapshot {
2026    fn drop(&mut self) {
2027        self.post_scan_flag.store(false, Ordering::Release);
2028    }
2029}
2030
2031// SAFETY: every data field is Arc-shared and outlives the snapshot
2032// via its own refcount. The mutable cast in `apply_git_status_and_frecency`
2033// is consumed on the scan thread under the single-writer discipline.
2034unsafe impl Send for PostScanUnsafeSnapshot {}
2035unsafe impl Sync for PostScanUnsafeSnapshot {}
2036
2037/// A point-in-time snapshot of the file-scanning progress.
2038///
2039/// Returned by [`FilePicker::get_scan_progress`]. Useful for displaying
2040/// a progress indicator while the initial scan is running.
2041#[derive(Debug, Clone)]
2042pub struct ScanProgress {
2043    pub scanned_files_count: usize,
2044    pub is_scanning: bool,
2045    pub is_watcher_ready: bool,
2046    pub is_warmup_complete: bool,
2047}
2048
2049impl FileSync {
2050    pub(crate) fn discover_git_workdir(base_path: &Path) -> Option<PathBuf> {
2051        let git_workdir = Repository::discover(base_path)
2052            .ok()
2053            .and_then(|repo| repo.workdir().map(Path::to_path_buf))
2054            .map(crate::path_utils::normalize);
2055
2056        match &git_workdir {
2057            Some(workdir) => debug!("Git repository found at: {}", workdir.display()),
2058            None => warn!("No git repository found for path: {}", base_path.display()),
2059        }
2060
2061        git_workdir
2062    }
2063
2064    pub(crate) fn spawn_git_status(git_workdir: PathBuf) -> JoinHandle<Option<GitStatusCache>> {
2065        std::thread::spawn(move || {
2066            GitStatusCache::read_git_status(
2067                Some(git_workdir.as_path()),
2068                &mut crate::git::initial_scan_status_options(),
2069            )
2070        })
2071    }
2072
2073    /// Returns files immediately (searchable) and a handle to the in-progress
2074    /// git status computation. This avoids blocking on `git status` which can
2075    /// take 10+ seconds on very large repos (e.g. chromium).
2076    #[tracing::instrument(skip_all, name = "walk_filesystem", level = Level::INFO)]
2077    pub(crate) fn walk_filesystem(
2078        base_path: &Path,
2079        git_workdir: Option<PathBuf>,
2080        synced_files_count: &Arc<AtomicUsize>,
2081        shared_frecency: &SharedFrecency,
2082        mode: FFFMode,
2083        follow_symlinks: bool,
2084    ) -> Result<FileSync, Error> {
2085        let scan_start = std::time::Instant::now();
2086        info!("SCAN: Starting filesystem walk and git status (async)");
2087
2088        // Walk files (the fast part, typically 2-3s even on huge repos).
2089        let is_git_repo = git_workdir.is_some();
2090        let bg_threads = BACKGROUND_THREAD_POOL.current_num_threads();
2091
2092        let WalkOutput {
2093            dirs: mut walked_dirs,
2094            mut pairs,
2095            ignore_rules,
2096        } = crate::walk::walk_collect_files(
2097            base_path,
2098            is_git_repo,
2099            follow_symlinks,
2100            bg_threads,
2101            synced_files_count,
2102        )?;
2103        let ignore_rules = ignore_rules.map(Arc::new);
2104
2105        // group walked dirs and files with a dir part to the same order
2106        BACKGROUND_THREAD_POOL.install(|| {
2107            rayon::join(
2108                || {
2109                    pairs.par_sort_unstable_by(|(a, path_a), (b, path_b)| {
2110                        // SAFETY: `filename_offset` is always at a character boundary
2111                        let (a_dir, a_file) = path_a.split_at(a.path.filename_offset as usize);
2112                        let (b_dir, b_file) = path_b.split_at(b.path.filename_offset as usize);
2113                        a_dir.cmp(b_dir).then_with(|| a_file.cmp(b_file))
2114                    });
2115                },
2116                || walked_dirs.par_sort_unstable(),
2117            );
2118        });
2119        walked_dirs.dedup();
2120
2121        let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len());
2122        let dirs = populates_dirs_files_chunked_storage(&mut pairs, &walked_dirs, &mut builder);
2123        drop(walked_dirs);
2124
2125        let mut files: Vec<FileItem> = pairs.into_iter().map(|(file, _)| file).collect();
2126        let chunked_paths = builder.finish();
2127        let arena = chunked_paths.as_arena_ptr();
2128
2129        // Apply frecency scores (access-based only — git status not yet available).
2130        // DirItem.max_access_frecency is AtomicI32, so parallel threads write directly.
2131        let frecency = shared_frecency
2132            .read()
2133            .map_err(|_| Error::AcquireFrecencyLock)?;
2134
2135        if let Some(frecency) = frecency.as_ref() {
2136            let dirs_ref = &dirs;
2137            BACKGROUND_THREAD_POOL.install(|| {
2138                files.par_iter_mut().for_each(|file| {
2139                    let _ = file.update_frecency_scores(frecency, arena, base_path, mode);
2140                    let score = file.access_frecency_score as i32;
2141                    if score > 0 {
2142                        let dir_idx = file.parent_dir_index as usize;
2143                        if let Some(dir) = dirs_ref.get(dir_idx) {
2144                            dir.update_frecency_if_larger(score);
2145                        }
2146                    }
2147                });
2148            });
2149        }
2150        drop(frecency);
2151
2152        // un-indexable files that are binary or not fitting the size cap has to beplaced in the end
2153        let is_indexable = |f: &FileItem| {
2154            !f.is_binary()
2155                && f.size > 0
2156                && f.size <= crate::constants::MAX_INDEXABLE_FILE_SIZE as u64
2157        };
2158
2159        BACKGROUND_THREAD_POOL.install(|| {
2160            files.par_sort_unstable_by(|a, b| {
2161                (!is_indexable(a))
2162                    .cmp(&!is_indexable(b))
2163                    // this just makes it faster in terms of allocation - we store the dir indexes
2164                    .then_with(|| a.parent_dir_index.cmp(&b.parent_dir_index))
2165                    .then_with(|| a.file_name(arena).cmp(&b.file_name(arena)))
2166            });
2167        });
2168        let indexable_count = files.partition_point(is_indexable);
2169
2170        // Ask the allocator to return freed pages to the OS.
2171        hint_allocator_collect();
2172
2173        let file_item_size = std::mem::size_of::<FileItem>();
2174        let files_vec_bytes = files.len() * file_item_size;
2175        let dir_table_bytes = dirs.len() * std::mem::size_of::<DirItem>()
2176            + dirs
2177                .iter()
2178                .map(|d| d.relative_path(arena).len())
2179                .sum::<usize>();
2180
2181        let total_time = scan_start.elapsed();
2182        info!(
2183            "SCAN: Walk completed in {:?} ({} files, {} dirs, \
2184         chunked_store={:.2}MB, files_vec={:.2}MB, dirs={:.2}MB, FileItem={}B)",
2185            total_time,
2186            files.len(),
2187            dirs.len(),
2188            chunked_paths.heap_bytes() as f64 / 1_048_576.0,
2189            files_vec_bytes as f64 / 1_048_576.0,
2190            dir_table_bytes as f64 / 1_048_576.0,
2191            file_item_size,
2192        );
2193
2194        let base_count = files.len();
2195        let base_dirs_count = dirs.len();
2196
2197        Ok(FileSync {
2198            files: StableVec::from_vec_with_reserve(files, MAX_OVERFLOW_FILES),
2199            indexable_count,
2200            base_count,
2201            live_count: base_count,
2202            dirs: StableVec::from_vec_with_reserve(dirs, MAX_OVERFLOW_FILES),
2203            base_dirs_count,
2204            live_dirs_count: base_dirs_count,
2205            overflow_builder: None,
2206            git_workdir,
2207            bigram_index: None,
2208            bigram_overlay: None,
2209            chunked_paths: Some(Arc::new(chunked_paths)),
2210            ignore_rules,
2211        })
2212    }
2213}
2214
2215/// Pre-populate mmap caches for cold tail files so the first grep search
2216/// doesn't pay the mmap creation + page fault cost.
2217#[allow(dead_code)]
2218#[tracing::instrument(skip(files), name = "warmup_mmaps", level = Level::DEBUG)]
2219pub(crate) fn warmup_mmaps(
2220    files: &[FileItem],
2221    budget: &ContentCacheBudget,
2222    base_path: &Path,
2223    arena: ArenaPtr,
2224) {
2225    // for most of the use cases mmaps limit would be significantly smaller than arepo
2226    for file in files.iter() {
2227        if file.is_likely_hot()
2228            || file.is_binary()
2229            || file.size == 0
2230            || file.size > budget.max_file_size
2231        {
2232            continue;
2233        }
2234
2235        let _ = file.get_cached_content(arena, base_path, budget);
2236
2237        if budget.is_exhausted() {
2238            break;
2239        }
2240    }
2241}
2242
2243/// This does both thing (yes sorry all the OOP morons)
2244/// in one go: populates files chunked storage and builds the dir table from
2245/// `walked_dirs` (every dir the walker visited: sorted, '/'-terminated,
2246/// deduped), merging file parents in a single lockstep sweep so dirs with no
2247/// files (empty subtrees, pure ancestors) are indexed and searchable too.
2248fn populates_dirs_files_chunked_storage<'a>(
2249    pairs: &'a mut [(FileItem, String)],
2250    walked_dirs: &[String],
2251    chunk_storage: &mut crate::simd_path::ChunkedPathStoreBuilder,
2252) -> Vec<DirItem> {
2253    let mut dirs: Vec<DirItem> = Vec::with_capacity(walked_dirs.len() + 1);
2254    let mut dir_iter = walked_dirs.iter().peekable();
2255
2256    // Root-level files sort first and their "" parent is never a walker dir.
2257    if pairs
2258        .first()
2259        .is_some_and(|(f, _)| f.path.filename_offset == 0)
2260    {
2261        push_dir_item(&mut dirs, chunk_storage, "");
2262    }
2263
2264    // Detects contiguous same-dir runs (pairs are sorted by dir) so the
2265    // merge below runs once per directory, not once per file.
2266    let mut prev_dir: &'a str = "";
2267    let mut current_dir_idx: u32 = 0;
2268
2269    for (file, rel) in pairs.iter_mut() {
2270        let rel: &'a str = rel;
2271        let dir_part: &'a str = &rel[..file.path.filename_offset as usize];
2272
2273        if prev_dir != dir_part {
2274            // Flush walked dirs up to and including this file's parent,
2275            // keeping the table sorted for the find_dir_index binary search.
2276            while let Some(dir) = dir_iter.peek()
2277                && dir.as_str() < dir_part
2278            {
2279                push_dir_item(&mut dirs, chunk_storage, dir);
2280                dir_iter.next();
2281            }
2282
2283            match dir_iter.peek() {
2284                Some(dir) if dir.as_str() == dir_part => {
2285                    push_dir_item(&mut dirs, chunk_storage, dir);
2286                    dir_iter.next();
2287                }
2288                // Parents the walker reported with a non-dir kind
2289                // (e.g. followed symlinks) aren't in the list.
2290                _ => push_dir_item(&mut dirs, chunk_storage, dir_part),
2291            }
2292
2293            current_dir_idx = (dirs.len() - 1) as u32;
2294            prev_dir = dir_part;
2295        }
2296
2297        file.path = chunk_storage.add_file_immediate(rel, file.path.filename_offset);
2298        file.parent_dir_index = current_dir_idx;
2299    }
2300
2301    for dir in dir_iter {
2302        push_dir_item(&mut dirs, chunk_storage, dir);
2303    }
2304
2305    dirs
2306}
2307
2308fn push_dir_item(
2309    dirs: &mut Vec<DirItem>,
2310    chunk_storage: &mut crate::simd_path::ChunkedPathStoreBuilder,
2311    dir_part: &str,
2312) {
2313    let dir_string = chunk_storage.add_dir_immediate(dir_part);
2314
2315    // Compute last-segment offset: for "src/components/" -> 4 (points to "components/")
2316    let last_seg = if dir_part.is_empty() {
2317        0
2318    } else {
2319        let trimmed = dir_part.trim_end_matches(std::path::is_separator);
2320        trimmed
2321            .rfind(std::path::is_separator)
2322            .map(|i| i + 1)
2323            .unwrap_or(0) as u16
2324    };
2325
2326    dirs.push(DirItem::new(dir_string, last_seg));
2327}
2328
2329/// Fast extension-based binary detection. Avoids opening files during scan.
2330/// Covers the vast majority of binary files in typical repositories.
2331#[inline]
2332#[doc(hidden)]
2333pub fn is_known_binary_extension(path: &Path) -> bool {
2334    let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
2335        return false;
2336    };
2337    is_binary_extension_str(ext)
2338}
2339
2340/// Like [`is_known_binary_extension`] but takes a basename string directly,
2341/// avoiding `Path::extension()` overhead. Mirrors `Path::extension()`
2342/// semantics: dotfiles with no other dots → no extension. Used by the zlob
2343/// walker, which already has the basename slice from traversal.
2344#[cfg(feature = "zlob")]
2345#[inline]
2346pub(crate) fn is_known_binary_extension_basename(name: &str) -> bool {
2347    match name.rfind('.') {
2348        Some(pos) if pos > 0 && pos < name.len() - 1 => is_binary_extension_str(&name[pos + 1..]),
2349        _ => false,
2350    }
2351}
2352
2353#[inline]
2354fn is_binary_extension_str(ext: &str) -> bool {
2355    matches!(
2356        ext,
2357        // Images
2358        "png" | "jpg" | "jpeg" | "gif" | "bmp" | "ico" | "webp" | "tiff" | "tif" | "avif" |
2359        "heic" | "heif" | "jxl" | "jp2" | "j2k" | "psd" | "icns" | "cur" | "cr2" |
2360        "nef" | "dng" | "tga" |
2361        // GPU / VFX texture formats
2362        "rgbe" | "hdr" | "exr" | "dds" | "ktx" | "ktx2" | "pvr" | "astc" |
2363        // Adobe Illustrator (PDF wrapper) / Apple webarchive / MIME HTML archive
2364        "ai" | "webarchive" | "mhtml" |
2365        // Video/Audio
2366        "mp4" | "avi" | "mov" | "wmv" | "mkv" | "mp3" | "wav" | "flac" | "ogg" | "m4a" |
2367        "aac" | "webm" | "flv" | "mpg" | "mpeg" | "wma" | "opus" | "pcm" | "reapeaks" |
2368        // Compressed/Archives
2369        "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar" | "zst" | "lz4" | "lzma" |
2370        "cab" | "cpio" | "jsonlz4" |
2371        // Packages/Installers
2372        "deb" | "rpm" | "apk" | "dmg" | "msi" | "iso" | "nupkg" | "whl" | "egg" |
2373        "appimage" | "flatpak" | "crx" | "pak" |
2374        // Executables/Libraries
2375        "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib" | "bin" | "elf" |
2376        // Documents (binary office formats)
2377        "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx" |
2378        // Databases
2379        "db" | "sqlite" | "sqlite3" | "mdb" |
2380        // SQLite / LevelDB auxiliary files
2381        "sqlite-wal" | "sqlite-shm" | "sqlite3-wal" | "sqlite3-shm" |
2382        "db-wal" | "db-shm" | "ldb" |
2383        // Fonts
2384        "ttf" | "otf" | "woff" | "woff2" | "eot" |
2385        // Compiled/Runtime
2386        "class" | "pyc" | "pyo" | "wasm" | "dex" | "jar" | "war" |
2387        // OCaml / Swift / Objective-C build artefacts
2388        "cmi" | "cmt" | "cmti" | "cmx" | "nib" |
2389        "swiftdeps" | "swiftdeps~" | "swiftdoc" | "swiftmodule" | "swiftsourceinfo" |
2390        // ML/Data Science
2391        "npy" | "npz" | "h5" | "hdf5" | "pt" | "onnx" |
2392        "safetensors" | "tfrecord" | "tflite" | "gguf" | "ggml" | "joblib" |
2393        // 3D/Game assets
2394        "glb" | "blend" | "blp" |
2395        // Gzipped-XML / binary maps
2396        "dia" | "bcmap" |
2397        // Protobuf wire format
2398        "pb" |
2399        // Data/serialized
2400        "parquet" | "arrow" |
2401        // IDE/OS metadata
2402        "suo"
2403    )
2404}
2405
2406/// Length of the longest shared directory prefix of two relative dir
2407/// paths (without a trailing separator), measured as the number of bytes
2408/// up to and including the last shared separator — plus the full shorter
2409/// path when it is itself a directory prefix of the longer one.
2410///
2411/// Examples:
2412///   `"src/components"` vs `"src/routes"`   → 4  (`"src/"` emitted once)
2413///   `"lib/deep/nested"` vs `"lib/deep"`   → 8  (`"lib/deep"` is a prefix)
2414///   `"lib/deep"` vs `"lib/deeper"`        → 4  (only `"lib/"` is shared)
2415///   `"lib"` vs `"src"`                    → 0
2416///
2417/// Used by [`FilePicker::for_each_watch_dir`] to avoid re-emitting
2418/// ancestors that were already yielded for the previous (sorted) sibling.
2419fn common_dir_prefix_len(a: &str, b: &str) -> usize {
2420    let max = a.len().min(b.len());
2421    let a_bytes = a.as_bytes();
2422    let b_bytes = b.as_bytes();
2423    let mut last_sep = 0;
2424    let mut i = 0;
2425    while i < max && a_bytes[i] == b_bytes[i] {
2426        if std::path::is_separator(a_bytes[i] as char) {
2427            last_sep = i + 1;
2428        }
2429        i += 1;
2430    }
2431    // If one string is a prefix of the other and the next byte in the
2432    // longer one is a separator, the full shorter path is a shared dir.
2433    if i == max && i > 0 {
2434        let longer = if a.len() > b.len() { a_bytes } else { b_bytes };
2435        if i < longer.len() && std::path::is_separator(longer[i] as char) {
2436            return i;
2437        }
2438    }
2439    last_sep
2440}
2441
2442/// Keep mimalloc off 2 MiB huge pages: with THP the arena is resident at
2443/// 2 MiB granularity and idle index memory inflates RSS by ~2x. Env overrides win.
2444/// Must run before the first allocation (see `fff_nvim`'s init-array hook).
2445#[cfg(feature = "mimalloc-collect")]
2446pub extern "C" fn tune_mimalloc() {
2447    // SAFETY: getenv/mi_option_set touch static tables only; no allocation happens here.
2448    unsafe {
2449        let user_set = !libc::getenv(c"MIMALLOC_ALLOW_LARGE_OS_PAGES".as_ptr()).is_null()
2450            || !libc::getenv(c"MIMALLOC_LARGE_OS_PAGES".as_ptr()).is_null();
2451        if !user_set {
2452            libmimalloc_sys::mi_option_set(libmimalloc_sys::mi_option_large_os_pages, 0);
2453        }
2454    }
2455}
2456
2457/// Ask the global allocator to return freed pages to the OS.
2458/// Enabled via the `mimalloc-collect` feature (set by fff-nvim).
2459/// No-op when the feature is off (tests, system allocator).
2460pub(crate) fn hint_allocator_collect() {
2461    #[cfg(feature = "mimalloc-collect")]
2462    {
2463        // Collect BACKGROUND_THREAD_POOL workers — that's where the bigram
2464        // builder allocated memory. `rayon::broadcast` would target the global
2465        // pool, which is the wrong set of threads.
2466        BACKGROUND_THREAD_POOL.broadcast(|_| unsafe { libmimalloc_sys::mi_collect(true) });
2467
2468        // Main thread too.
2469        unsafe { libmimalloc_sys::mi_collect(true) };
2470    }
2471}
2472
2473#[cfg(test)]
2474mod tests {
2475    use super::*;
2476
2477    /// The watcher must watch every ancestor directory up to `base_path`,
2478    /// not just the immediate parents of indexed files. The dir table is
2479    /// built from the walker's visited dirs, so pure ancestors (dirs that
2480    /// contain only subdirectories) must be present and emitted exactly once.
2481    #[test]
2482    fn extract_watch_dirs_includes_pure_ancestor_dirs() {
2483        let dir = tempfile::tempdir().unwrap();
2484        // On Windows the picker canonicalizes base_path with dunce; match that
2485        // here so the stored dir paths line up with assertions built from
2486        // `base.join(..)` (which otherwise would carry an 8.3 short name).
2487        let base_buf = crate::path_utils::canonicalize(dir.path()).unwrap();
2488        let base = base_buf.as_path();
2489
2490        // Tree:
2491        //   base/src/components/button.txt    (src/components has a file)
2492        //   base/src/routes/home.txt          (src/routes has a file)
2493        //   base/lib/deep/nested/util.txt     (lib and lib/deep have no files)
2494        for rel in [
2495            "src/components/button.txt",
2496            "src/routes/home.txt",
2497            "lib/deep/nested/util.txt",
2498        ] {
2499            let path = base.join(rel);
2500            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2501            std::fs::write(&path, b"x").unwrap();
2502        }
2503
2504        let mut picker = FilePicker::new(FilePickerOptions {
2505            base_path: base.to_str().unwrap().into(),
2506            watch: false,
2507            ..Default::default()
2508        })
2509        .unwrap();
2510        picker.collect_files().unwrap();
2511
2512        let mut watch_dirs: Vec<PathBuf> = Vec::new();
2513        picker.for_each_dir(|p| {
2514            watch_dirs.push(p.to_path_buf());
2515            std::ops::ControlFlow::Continue(())
2516        });
2517        let watch_set: std::collections::HashSet<PathBuf> = watch_dirs.iter().cloned().collect();
2518
2519        // Immediate parents (in sync_data.dirs) must be present.
2520        for rel in ["src/components", "src/routes", "lib/deep/nested"] {
2521            assert!(
2522                watch_set.contains(&base.join(rel)),
2523                "expected immediate parent {rel} in watch dirs, got {watch_set:?}",
2524            );
2525        }
2526
2527        // Pure-ancestor dirs (NOT in sync_data.dirs) must also be present.
2528        for rel in ["src", "lib", "lib/deep"] {
2529            assert!(
2530                watch_set.contains(&base.join(rel)),
2531                "expected pure-ancestor {rel} in watch dirs, got {watch_set:?}",
2532            );
2533        }
2534
2535        // No duplicates — streaming dedup must not emit the same dir twice.
2536        assert_eq!(
2537            watch_dirs.len(),
2538            watch_set.len(),
2539            "duplicate watch dir emitted: {watch_dirs:?}",
2540        );
2541
2542        // Base path itself is NOT walked into the result — the walker stops
2543        // at `current == base`. The outer `debouncer.watch(base_path, ...)`
2544        // call in create_debouncer covers it separately.
2545        assert!(
2546            !watch_set.contains(base),
2547            "base path must not be in watch dirs (covered by the top-level watch call)",
2548        );
2549    }
2550
2551    /// Regression guard for #725: dirs that are EMPTY at scan time are merged
2552    /// into `sync_data.dirs` so they are searchable and get an inotify watch;
2553    /// files created in them later must be detected.
2554    #[test]
2555    fn for_each_dir_includes_empty_directories() {
2556        let dir = tempfile::tempdir().unwrap();
2557        let base_buf = crate::path_utils::canonicalize(dir.path()).unwrap();
2558        let base = base_buf.as_path();
2559
2560        // Tree:
2561        //   base/init.lua                  (file directly under base)
2562        //   base/commands/                 (empty at scan — the #725 repro)
2563        //   base/src/main.rs               (src is indexed)
2564        //   base/src/plugins/extra/        (empty chain under an indexed dir)
2565        std::fs::create_dir_all(base.join("commands")).unwrap();
2566        std::fs::create_dir_all(base.join("src/plugins/extra")).unwrap();
2567        std::fs::write(base.join("init.lua"), b"x").unwrap();
2568        std::fs::write(base.join("src/main.rs"), b"x").unwrap();
2569
2570        let mut picker = FilePicker::new(FilePickerOptions {
2571            base_path: base.to_str().unwrap().into(),
2572            watch: false,
2573            ..Default::default()
2574        })
2575        .unwrap();
2576        picker.collect_files().unwrap();
2577
2578        let mut watch_dirs: Vec<PathBuf> = Vec::new();
2579        picker.for_each_dir(|p| {
2580            watch_dirs.push(p.to_path_buf());
2581            std::ops::ControlFlow::Continue(())
2582        });
2583        let watch_set: std::collections::HashSet<PathBuf> = watch_dirs.iter().cloned().collect();
2584
2585        for rel in ["commands", "src/plugins", "src/plugins/extra", "src"] {
2586            assert!(
2587                watch_set.contains(&base.join(rel)),
2588                "expected {rel} in watch dirs, got {watch_set:?}",
2589            );
2590        }
2591
2592        // Dirs covered by indexed files must not be duplicated.
2593        assert_eq!(
2594            watch_dirs.len(),
2595            watch_set.len(),
2596            "duplicate watch dir emitted: {watch_dirs:?}",
2597        );
2598    }
2599
2600    #[test]
2601    fn dir_table_merges_walked_dirs_with_file_parents() {
2602        let mut pairs: Vec<(FileItem, String)> = ["src/main.rs", "src/deep/lib.rs", "root.txt"]
2603            .iter()
2604            .map(|p| {
2605                let (item, rel) = FileItem::new(PathBuf::from(p), Path::new(""), None);
2606                (item, rel)
2607            })
2608            .collect();
2609        pairs.sort_by(|(a, pa), (b, pb)| {
2610            pa[..a.path.filename_offset as usize]
2611                .cmp(&pb[..b.path.filename_offset as usize])
2612                .then_with(|| pa.cmp(pb))
2613        });
2614
2615        // Sorted '/'-terminated walker output: file parents + an empty dir +
2616        // a sibling sharing a prefix with a file parent.
2617        let walked: Vec<String> = ["empty/", "src/", "src/deep/", "src/deeper/"]
2618            .iter()
2619            .map(|s| s.to_string())
2620            .collect();
2621
2622        let mut builder = crate::simd_path::ChunkedPathStoreBuilder::new(pairs.len());
2623        let dirs = populates_dirs_files_chunked_storage(&mut pairs, &walked, &mut builder);
2624        let store = builder.finish();
2625        let arena = store.as_arena_ptr();
2626
2627        let table: Vec<String> = dirs.iter().map(|d| d.relative_path(arena)).collect();
2628        // Sorted: "" (root files) first, all walked dirs present exactly once.
2629        assert_eq!(table, ["", "empty/", "src/", "src/deep/", "src/deeper/"]);
2630
2631        // Every file's parent_dir_index points at its own dir entry.
2632        for (file, _) in &pairs {
2633            let dir = &dirs[file.parent_dir_index as usize];
2634            let rel = file.relative_path(arena);
2635            assert!(
2636                rel.starts_with(&dir.relative_path(arena)),
2637                "file {rel} must live under its parent dir",
2638            );
2639        }
2640    }
2641
2642    #[test]
2643    fn common_dir_prefix_len_cases() {
2644        assert_eq!(common_dir_prefix_len("", ""), 0);
2645        assert_eq!(common_dir_prefix_len("", "src"), 0);
2646        assert_eq!(common_dir_prefix_len("lib", "src"), 0);
2647        assert_eq!(common_dir_prefix_len("src/components", "src/routes"), 4);
2648        assert_eq!(common_dir_prefix_len("lib/deep/nested", "lib/deep"), 8);
2649        assert_eq!(common_dir_prefix_len("lib/deep", "lib/deep/nested"), 8);
2650        assert_eq!(common_dir_prefix_len("lib/deep", "lib/deeper"), 4);
2651        assert_eq!(common_dir_prefix_len("src", "src"), 0);
2652        // "src" is emitted-as-dir; "src/x" extends it — full "src" is shared.
2653        assert_eq!(common_dir_prefix_len("src", "src/x"), 3);
2654    }
2655
2656    #[test]
2657    fn directory_removal_collects_each_tombstoned_path() {
2658        let dir = tempfile::tempdir().unwrap();
2659        let base = crate::path_utils::canonicalize(dir.path()).unwrap();
2660        let removed_dir = base.join("removed");
2661        let kept = base.join("kept.txt");
2662        let first = removed_dir.join("a.txt");
2663        let second = removed_dir.join("nested/b.txt");
2664        std::fs::create_dir_all(second.parent().unwrap()).unwrap();
2665        std::fs::write(&first, b"a").unwrap();
2666        std::fs::write(&second, b"b").unwrap();
2667        std::fs::write(&kept, b"kept").unwrap();
2668
2669        let mut picker = FilePicker::new(FilePickerOptions {
2670            base_path: base.to_string_lossy().into_owned(),
2671            watch: false,
2672            ..Default::default()
2673        })
2674        .unwrap();
2675        picker.collect_files().unwrap();
2676
2677        let mut removed = Vec::new();
2678        assert_eq!(
2679            picker.remove_all_files_in_dirs_with_callback(
2680                std::iter::once(removed_dir.as_path()),
2681                |path| {
2682                    removed.push(path.to_path_buf());
2683                }
2684            ),
2685            2
2686        );
2687        removed.sort_unstable();
2688        assert_eq!(removed, vec![first, second]);
2689        assert!(picker.get_file_by_path(&kept).is_some());
2690
2691        let outside = base.parent().unwrap().join("outside");
2692        assert_eq!(picker.remove_all_files_in_dir(&outside), 0);
2693        assert!(picker.get_file_by_path(&kept).is_some());
2694    }
2695}