1use 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}