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 len(&self) -> usize {
107        self.inner
108            .entries
109            .read()
110            .map(|entries| entries.len())
111            .unwrap_or_default()
112    }
113
114    pub fn is_empty(&self) -> bool {
115        self.len() == 0
116    }
117
118    pub fn search(&self, query: &str, limit: usize) -> Vec<FileSearchMatch> {
119        if limit == 0 {
120            return Vec::new();
121        }
122        let query = query.trim();
123        let candidates = self
124            .inner
125            .entries
126            .read()
127            .map(|entries| entries.clone())
128            .unwrap_or_default();
129        let (direct_candidates, direct_fragment) =
130            direct_path_candidates(query, &self.inner.root, &self.inner.options);
131
132        let direct_pattern = Pattern::new(
133            &direct_fragment,
134            CaseMatching::Ignore,
135            Normalization::Smart,
136            AtomKind::Fuzzy,
137        );
138        let mut direct_matcher = Matcher::new(MatcherConfig::DEFAULT);
139        let mut direct_utf32_buffer = Vec::new();
140        let mut direct_matches = direct_candidates
141            .into_iter()
142            .filter_map(|entry| {
143                let score = if direct_fragment.is_empty() {
144                    1
145                } else {
146                    direct_pattern.score(
147                        Utf32Str::new(&entry.name, &mut direct_utf32_buffer),
148                        &mut direct_matcher,
149                    )?
150                };
151                Some(FileSearchMatch { entry, score })
152            })
153            .collect::<Vec<_>>();
154        direct_matches.sort_by(|left, right| {
155            kind_rank(left.entry.kind)
156                .cmp(&kind_rank(right.entry.kind))
157                .then_with(|| right.score.cmp(&left.score))
158                .then_with(|| left.entry.path.cmp(&right.entry.path))
159        });
160
161        let mut seen = HashSet::new();
162        let mut matches = Vec::with_capacity(limit);
163        for direct_match in direct_matches.into_iter().take(limit) {
164            seen.insert(direct_match.entry.path.clone());
165            matches.push(direct_match);
166        }
167        if matches.len() == limit {
168            return matches;
169        }
170
171        let mut unique = HashMap::<PathBuf, FileIndexEntry>::new();
172        for entry in candidates {
173            unique.entry(entry.path.clone()).or_insert(entry);
174        }
175
176        let normalized_query = query
177            .strip_prefix(self.inner.root.to_string_lossy().as_ref())
178            .unwrap_or(query)
179            .trim_matches(['/', '\\'])
180            .to_string();
181        let pattern = Pattern::new(
182            &normalized_query,
183            CaseMatching::Ignore,
184            Normalization::Smart,
185            AtomKind::Fuzzy,
186        );
187        let mut matcher = Matcher::new(MatcherConfig::DEFAULT.match_paths());
188        let mut utf32_buffer = Vec::new();
189        let mut fuzzy_matches = unique
190            .into_values()
191            .filter(|entry| !seen.contains(&entry.path))
192            .filter_map(|entry| {
193                let relative = entry
194                    .path
195                    .strip_prefix(&self.inner.root)
196                    .unwrap_or(&entry.path)
197                    .to_string_lossy();
198                let score = if normalized_query.is_empty() {
199                    1
200                } else {
201                    pattern.score(
202                        Utf32Str::new(relative.as_ref(), &mut utf32_buffer),
203                        &mut matcher,
204                    )?
205                };
206                Some(FileSearchMatch { entry, score })
207            })
208            .collect::<Vec<_>>();
209        fuzzy_matches.sort_by(|left, right| {
210            right
211                .score
212                .cmp(&left.score)
213                .then_with(|| kind_rank(left.entry.kind).cmp(&kind_rank(right.entry.kind)))
214                .then_with(|| left.entry.path.cmp(&right.entry.path))
215        });
216        matches.extend(fuzzy_matches.into_iter().take(limit - matches.len()));
217        matches
218    }
219
220    pub fn resolve(path: impl AsRef<Path>) -> Result<FileIndexEntry, FileIndexError> {
221        let path = path.as_ref();
222        if !path.is_absolute() {
223            return Err(FileIndexError::RelativePath(path.display().to_string()));
224        }
225        if !path.exists() {
226            return Err(FileIndexError::MissingPath(path.display().to_string()));
227        }
228        let path = path.canonicalize()?;
229        entry_for_path(&path)
230            .ok_or_else(|| FileIndexError::UnsupportedPath(path.display().to_string()))
231    }
232}
233
234impl Drop for FileSearchIndexInner {
235    fn drop(&mut self) {
236        self.stopped.store(true, Ordering::Release);
237    }
238}
239
240fn kind_rank(kind: FileIndexEntryKind) -> u8 {
241    match kind {
242        FileIndexEntryKind::Directory => 0,
243        FileIndexEntryKind::File => 1,
244    }
245}
246
247fn normalized_extensions(options: &FileIndexOptions) -> HashSet<String> {
248    options
249        .extensions
250        .iter()
251        .map(|extension| extension.trim_start_matches('.').to_lowercase())
252        .filter(|extension| !extension.is_empty())
253        .collect()
254}
255
256fn is_supported_file(path: &Path, extensions: &HashSet<String>) -> bool {
257    extensions.is_empty()
258        || path
259            .extension()
260            .and_then(|extension| extension.to_str())
261            .map(|extension| extensions.contains(&extension.to_lowercase()))
262            .unwrap_or(false)
263}
264
265fn is_hidden(path: &Path) -> bool {
266    path.file_name()
267        .and_then(|name| name.to_str())
268        .map(|name| name.starts_with('.'))
269        .unwrap_or(false)
270}
271
272fn entry_for_path(path: &Path) -> Option<FileIndexEntry> {
273    let metadata = fs::metadata(path).ok()?;
274    let kind = if metadata.is_dir() {
275        FileIndexEntryKind::Directory
276    } else if metadata.is_file() {
277        FileIndexEntryKind::File
278    } else {
279        return None;
280    };
281    Some(FileIndexEntry {
282        name: path
283            .file_name()
284            .map(|name| name.to_string_lossy().into_owned())
285            .filter(|name| !name.is_empty())
286            .unwrap_or_else(|| path.display().to_string()),
287        path: path.to_path_buf(),
288        kind,
289        extension: path
290            .extension()
291            .and_then(|extension| extension.to_str())
292            .map(str::to_lowercase),
293    })
294}
295
296fn build_config(options: &FileIndexOptions, extensions: &[String]) -> FsIndexConfig {
297    FsIndexConfig::builder()
298        .respect_gitignore(options.respect_gitignore)
299        .include_hidden(options.include_hidden)
300        .follow_symlinks(options.follow_symlinks)
301        .extensions(extensions)
302        .read_contents(false)
303        .parse_structure(false)
304        .build()
305}
306
307fn build_entries(
308    root: &Path,
309    options: &FileIndexOptions,
310) -> Result<Vec<FileIndexEntry>, FileIndexError> {
311    let extensions = normalized_extensions(options)
312        .into_iter()
313        .collect::<Vec<_>>();
314    let config = build_config(options, &extensions);
315    let indexer = FileIndexer::with_config(root, config);
316    let mut entries = HashMap::<PathBuf, FileIndexEntry>::new();
317
318    for file in indexer.files_parallel() {
319        if let Some(entry) = entry_for_path(&file.path) {
320            entries.insert(entry.path.clone(), entry);
321        }
322        let mut ancestor = file.path.parent();
323        while let Some(directory) = ancestor {
324            if !directory.starts_with(root) {
325                break;
326            }
327            if let Some(entry) = entry_for_path(directory) {
328                entries.entry(entry.path.clone()).or_insert(entry);
329            }
330            if directory == root {
331                break;
332            }
333            ancestor = directory.parent();
334        }
335    }
336
337    Ok(entries.into_values().collect())
338}
339
340fn direct_path_candidates(
341    query: &str,
342    root: &Path,
343    options: &FileIndexOptions,
344) -> (Vec<FileIndexEntry>, String) {
345    let candidate = Path::new(query);
346    let resolved = if query.is_empty() {
347        root.to_path_buf()
348    } else if candidate.is_absolute() {
349        candidate.to_path_buf()
350    } else {
351        root.join(candidate)
352    };
353    let (directory, fragment) = if resolved.is_dir() {
354        (resolved, String::new())
355    } else {
356        (
357            resolved
358                .parent()
359                .filter(|parent| parent.is_dir())
360                .unwrap_or(root)
361                .to_path_buf(),
362            resolved
363                .file_name()
364                .map(|name| name.to_string_lossy().into_owned())
365                .unwrap_or_default(),
366        )
367    };
368    let extensions = normalized_extensions(options);
369    let Ok(children) = fs::read_dir(&directory) else {
370        return (Vec::new(), fragment);
371    };
372    let entries = children
373        .filter_map(Result::ok)
374        .filter_map(|child| {
375            let path = child.path();
376            if !options.include_hidden && is_hidden(&path) {
377                return None;
378            }
379            let entry = entry_for_path(&path)?;
380            if entry.kind == FileIndexEntryKind::File && !is_supported_file(&path, &extensions) {
381                return None;
382            }
383            Some(entry)
384        })
385        .collect();
386    (entries, fragment)
387}
388
389fn start_watcher(inner: &Arc<FileSearchIndexInner>) {
390    let weak = Arc::downgrade(inner);
391    let root = inner.root.clone();
392    let options = inner.options.clone();
393    thread::spawn(move || {
394        let watcher_config = build_config(&options, &[]);
395        let watcher = match fsindex::FileWatcher::new(&root, watcher_config) {
396            Ok(watcher) => watcher,
397            Err(_) => return,
398        };
399        while let Some(inner) = weak.upgrade() {
400            if inner.stopped.load(Ordering::Acquire) {
401                break;
402            }
403            let Some(event) = watcher.next_timeout(Duration::from_millis(500)) else {
404                continue;
405            };
406            let Ok(event) = event else {
407                continue;
408            };
409            if event.kind == EventKind::Accessed {
410                continue;
411            }
412            thread::sleep(Duration::from_millis(180));
413            if let Ok(entries) = build_entries(&root, &options) {
414                if let Ok(mut current) = inner.entries.write() {
415                    *current = entries;
416                }
417            }
418        }
419    });
420}