Skip to main content

squigit/
file_index.rs

1// Copyright 2026 a7mddra
2// SPDX-License-Identifier: Apache-2.0
3
4use fsindex::{Config as FsIndexConfig, EventKind, FileIndexer};
5use nucleo_matcher::{
6    pattern::{AtomKind, CaseMatching, Normalization, Pattern},
7    Config as MatcherConfig, Matcher, Utf32Str,
8};
9use std::{
10    collections::{HashMap, HashSet},
11    fs,
12    path::{Path, PathBuf},
13    sync::{
14        atomic::{AtomicBool, Ordering},
15        Arc, RwLock,
16    },
17    thread,
18    time::Duration,
19};
20use thiserror::Error;
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum FileIndexEntryKind {
24    Directory,
25    File,
26}
27
28#[derive(Clone, Debug)]
29pub struct FileIndexEntry {
30    pub name: String,
31    pub path: PathBuf,
32    pub kind: FileIndexEntryKind,
33    pub extension: Option<String>,
34}
35
36#[derive(Clone, Debug)]
37pub struct FileSearchMatch {
38    pub entry: FileIndexEntry,
39    pub score: u32,
40}
41
42#[derive(Clone, Debug)]
43pub struct FileIndexOptions {
44    pub extensions: Vec<String>,
45    pub include_hidden: bool,
46    pub respect_gitignore: bool,
47    pub follow_symlinks: bool,
48}
49
50impl Default for FileIndexOptions {
51    fn default() -> Self {
52        Self {
53            extensions: Vec::new(),
54            include_hidden: false,
55            respect_gitignore: true,
56            follow_symlinks: false,
57        }
58    }
59}
60
61#[derive(Debug, Error)]
62pub enum FileIndexError {
63    #[error("The index root is not an absolute directory: {0}")]
64    InvalidRoot(String),
65    #[error("The path is not absolute: {0}")]
66    RelativePath(String),
67    #[error("The path does not exist: {0}")]
68    MissingPath(String),
69    #[error("The path is not a file or directory: {0}")]
70    UnsupportedPath(String),
71    #[error("Filesystem operation failed: {0}")]
72    Io(#[from] std::io::Error),
73}
74
75#[derive(Debug)]
76struct FileSearchIndexInner {
77    root: PathBuf,
78    options: FileIndexOptions,
79    entries: RwLock<Vec<FileIndexEntry>>,
80    stopped: AtomicBool,
81}
82
83#[derive(Clone, Debug)]
84pub struct FileSearchIndex {
85    inner: Arc<FileSearchIndexInner>,
86}
87
88impl FileSearchIndex {
89    pub fn new(root: impl AsRef<Path>, options: FileIndexOptions) -> Result<Self, FileIndexError> {
90        let root = root.as_ref();
91        if !root.is_absolute() || !root.is_dir() {
92            return Err(FileIndexError::InvalidRoot(root.display().to_string()));
93        }
94        let root = root.canonicalize()?;
95        let entries = build_entries(&root, &options)?;
96        let inner = Arc::new(FileSearchIndexInner {
97            root,
98            options,
99            entries: RwLock::new(entries),
100            stopped: AtomicBool::new(false),
101        });
102        start_watcher(&inner);
103        Ok(Self { inner })
104    }
105
106    pub fn search(&self, query: &str, limit: usize) -> Vec<FileSearchMatch> {
107        if limit == 0 {
108            return Vec::new();
109        }
110        let query = query.trim();
111        let candidates = self
112            .inner
113            .entries
114            .read()
115            .map(|entries| entries.clone())
116            .unwrap_or_default();
117        let (direct_candidates, direct_fragment) =
118            direct_path_candidates(query, &self.inner.root, &self.inner.options);
119
120        let direct_pattern = Pattern::new(
121            &direct_fragment,
122            CaseMatching::Ignore,
123            Normalization::Smart,
124            AtomKind::Fuzzy,
125        );
126        let mut direct_matcher = Matcher::new(MatcherConfig::DEFAULT);
127        let mut direct_utf32_buffer = Vec::new();
128        let mut direct_matches = direct_candidates
129            .into_iter()
130            .filter_map(|entry| {
131                let score = if direct_fragment.is_empty() {
132                    1
133                } else {
134                    direct_pattern.score(
135                        Utf32Str::new(&entry.name, &mut direct_utf32_buffer),
136                        &mut direct_matcher,
137                    )?
138                };
139                Some(FileSearchMatch { entry, score })
140            })
141            .collect::<Vec<_>>();
142        direct_matches.sort_by(|left, right| {
143            kind_rank(left.entry.kind)
144                .cmp(&kind_rank(right.entry.kind))
145                .then_with(|| right.score.cmp(&left.score))
146                .then_with(|| left.entry.path.cmp(&right.entry.path))
147        });
148
149        let mut seen = HashSet::new();
150        let mut matches = Vec::with_capacity(limit);
151        for direct_match in direct_matches.into_iter().take(limit) {
152            seen.insert(direct_match.entry.path.clone());
153            matches.push(direct_match);
154        }
155        if matches.len() == limit {
156            return matches;
157        }
158
159        let mut unique = HashMap::<PathBuf, FileIndexEntry>::new();
160        for entry in candidates {
161            unique.entry(entry.path.clone()).or_insert(entry);
162        }
163
164        let normalized_query = query
165            .strip_prefix(self.inner.root.to_string_lossy().as_ref())
166            .unwrap_or(query)
167            .trim_matches(['/', '\\'])
168            .to_string();
169        let pattern = Pattern::new(
170            &normalized_query,
171            CaseMatching::Ignore,
172            Normalization::Smart,
173            AtomKind::Fuzzy,
174        );
175        let mut matcher = Matcher::new(MatcherConfig::DEFAULT.match_paths());
176        let mut utf32_buffer = Vec::new();
177        let mut fuzzy_matches = unique
178            .into_values()
179            .filter(|entry| !seen.contains(&entry.path))
180            .filter_map(|entry| {
181                let relative = entry
182                    .path
183                    .strip_prefix(&self.inner.root)
184                    .unwrap_or(&entry.path)
185                    .to_string_lossy();
186                let score = if normalized_query.is_empty() {
187                    1
188                } else {
189                    pattern.score(
190                        Utf32Str::new(relative.as_ref(), &mut utf32_buffer),
191                        &mut matcher,
192                    )?
193                };
194                Some(FileSearchMatch { entry, score })
195            })
196            .collect::<Vec<_>>();
197        fuzzy_matches.sort_by(|left, right| {
198            right
199                .score
200                .cmp(&left.score)
201                .then_with(|| kind_rank(left.entry.kind).cmp(&kind_rank(right.entry.kind)))
202                .then_with(|| left.entry.path.cmp(&right.entry.path))
203        });
204        matches.extend(fuzzy_matches.into_iter().take(limit - matches.len()));
205        matches
206    }
207
208    pub fn resolve(path: impl AsRef<Path>) -> Result<FileIndexEntry, FileIndexError> {
209        let path = path.as_ref();
210        if !path.is_absolute() {
211            return Err(FileIndexError::RelativePath(path.display().to_string()));
212        }
213        if !path.exists() {
214            return Err(FileIndexError::MissingPath(path.display().to_string()));
215        }
216        let path = path.canonicalize()?;
217        entry_for_path(&path)
218            .ok_or_else(|| FileIndexError::UnsupportedPath(path.display().to_string()))
219    }
220}
221
222impl Drop for FileSearchIndexInner {
223    fn drop(&mut self) {
224        self.stopped.store(true, Ordering::Release);
225    }
226}
227
228fn kind_rank(kind: FileIndexEntryKind) -> u8 {
229    match kind {
230        FileIndexEntryKind::Directory => 0,
231        FileIndexEntryKind::File => 1,
232    }
233}
234
235fn normalized_extensions(options: &FileIndexOptions) -> HashSet<String> {
236    options
237        .extensions
238        .iter()
239        .map(|extension| extension.trim_start_matches('.').to_lowercase())
240        .filter(|extension| !extension.is_empty())
241        .collect()
242}
243
244fn is_supported_file(path: &Path, extensions: &HashSet<String>) -> bool {
245    extensions.is_empty()
246        || path
247            .extension()
248            .and_then(|extension| extension.to_str())
249            .map(|extension| extensions.contains(&extension.to_lowercase()))
250            .unwrap_or(false)
251}
252
253fn is_hidden(path: &Path) -> bool {
254    path.file_name()
255        .and_then(|name| name.to_str())
256        .map(|name| name.starts_with('.'))
257        .unwrap_or(false)
258}
259
260fn entry_for_path(path: &Path) -> Option<FileIndexEntry> {
261    let metadata = fs::metadata(path).ok()?;
262    let kind = if metadata.is_dir() {
263        FileIndexEntryKind::Directory
264    } else if metadata.is_file() {
265        FileIndexEntryKind::File
266    } else {
267        return None;
268    };
269    Some(FileIndexEntry {
270        name: path
271            .file_name()
272            .map(|name| name.to_string_lossy().into_owned())
273            .filter(|name| !name.is_empty())
274            .unwrap_or_else(|| path.display().to_string()),
275        path: path.to_path_buf(),
276        kind,
277        extension: path
278            .extension()
279            .and_then(|extension| extension.to_str())
280            .map(str::to_lowercase),
281    })
282}
283
284fn build_config(options: &FileIndexOptions, extensions: &[String]) -> FsIndexConfig {
285    FsIndexConfig::builder()
286        .respect_gitignore(options.respect_gitignore)
287        .include_hidden(options.include_hidden)
288        .follow_symlinks(options.follow_symlinks)
289        .extensions(extensions)
290        .read_contents(false)
291        .parse_structure(false)
292        .build()
293}
294
295fn build_entries(
296    root: &Path,
297    options: &FileIndexOptions,
298) -> Result<Vec<FileIndexEntry>, FileIndexError> {
299    let extensions = normalized_extensions(options)
300        .into_iter()
301        .collect::<Vec<_>>();
302    let config = build_config(options, &extensions);
303    let indexer = FileIndexer::with_config(root, config);
304    let mut entries = HashMap::<PathBuf, FileIndexEntry>::new();
305
306    for file in indexer.files_parallel() {
307        if let Some(entry) = entry_for_path(&file.path) {
308            entries.insert(entry.path.clone(), entry);
309        }
310        let mut ancestor = file.path.parent();
311        while let Some(directory) = ancestor {
312            if !directory.starts_with(root) {
313                break;
314            }
315            if let Some(entry) = entry_for_path(directory) {
316                entries.entry(entry.path.clone()).or_insert(entry);
317            }
318            if directory == root {
319                break;
320            }
321            ancestor = directory.parent();
322        }
323    }
324
325    Ok(entries.into_values().collect())
326}
327
328fn direct_path_candidates(
329    query: &str,
330    root: &Path,
331    options: &FileIndexOptions,
332) -> (Vec<FileIndexEntry>, String) {
333    let candidate = Path::new(query);
334    let resolved = if query.is_empty() {
335        root.to_path_buf()
336    } else if candidate.is_absolute() {
337        candidate.to_path_buf()
338    } else {
339        root.join(candidate)
340    };
341    let (directory, fragment) = if resolved.is_dir() {
342        (resolved, String::new())
343    } else {
344        (
345            resolved
346                .parent()
347                .filter(|parent| parent.is_dir())
348                .unwrap_or(root)
349                .to_path_buf(),
350            resolved
351                .file_name()
352                .map(|name| name.to_string_lossy().into_owned())
353                .unwrap_or_default(),
354        )
355    };
356    let extensions = normalized_extensions(options);
357    let Ok(children) = fs::read_dir(&directory) else {
358        return (Vec::new(), fragment);
359    };
360    let entries = children
361        .filter_map(Result::ok)
362        .filter_map(|child| {
363            let path = child.path();
364            if !options.include_hidden && is_hidden(&path) {
365                return None;
366            }
367            let entry = entry_for_path(&path)?;
368            if entry.kind == FileIndexEntryKind::File && !is_supported_file(&path, &extensions) {
369                return None;
370            }
371            Some(entry)
372        })
373        .collect();
374    (entries, fragment)
375}
376
377fn start_watcher(inner: &Arc<FileSearchIndexInner>) {
378    let weak = Arc::downgrade(inner);
379    let root = inner.root.clone();
380    let options = inner.options.clone();
381    thread::spawn(move || {
382        let watcher_config = build_config(&options, &[]);
383        let watcher = match fsindex::FileWatcher::new(&root, watcher_config) {
384            Ok(watcher) => watcher,
385            Err(_) => return,
386        };
387        while let Some(inner) = weak.upgrade() {
388            if inner.stopped.load(Ordering::Acquire) {
389                break;
390            }
391            let Some(event) = watcher.next_timeout(Duration::from_millis(500)) else {
392                continue;
393            };
394            let Ok(event) = event else {
395                continue;
396            };
397            if event.kind == EventKind::Accessed {
398                continue;
399            }
400            thread::sleep(Duration::from_millis(180));
401            if let Ok(entries) = build_entries(&root, &options) {
402                if let Ok(mut current) = inner.entries.write() {
403                    *current = entries;
404                }
405            }
406        }
407    });
408}