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