1use std::cmp::Reverse;
23use std::collections::BinaryHeap;
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27
28use arc_swap::ArcSwap;
29use ignore::WalkBuilder;
30use ignore::gitignore::GitignoreBuilder;
31
32use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
33use nucleo_matcher::{Config, Matcher, Utf32String};
34use notify::Watcher;
35
36
37pub struct FileEntry {
40 pub path: PathBuf,
42 utf32: Utf32String,
44}
45
46pub struct FileIndex {
47 files: Vec<FileEntry>,
48 trigrams: HashMap<[u8; 3], Vec<usize>>,
51}
52
53impl std::fmt::Debug for FileIndex {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 write!(f, "FileIndex({} files)", self.files.len())
56 }
57}
58
59impl FileIndex {
60 pub fn build_with_max_depth(root: &Path, max_depth: Option<usize>) -> Self {
64 let mut files = Vec::with_capacity(4096);
65 let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
66
67 let mut builder = WalkBuilder::new(root);
68 builder
69 .hidden(false) .git_ignore(true) .git_exclude(true)
72 .parents(true);
73
74 if let Some(d) = max_depth {
75 builder.max_depth(Some(d));
76 }
77
78 for result in builder.build() {
79 let Ok(entry) = result else { continue };
80 let Some(ft) = entry.file_type() else {
81 continue;
82 };
83 if !ft.is_file() {
84 continue;
85 }
86
87 let path = entry.path();
88 let rel = path.strip_prefix(root).unwrap_or(path);
89
90 if rel.components().any(|c| {
93 c.as_os_str()
94 .to_str()
95 .map(|s| matches!(s, "target" | "node_modules" | "__pycache__"))
96 .unwrap_or(false)
97 }) {
98 continue;
99 }
100
101 let s = rel.to_string_lossy().replace('\\', "/");
102 let idx = files.len();
103 index_trigrams(s.as_bytes(), idx, &mut trigrams);
104 files.push(FileEntry {
105 path: rel.to_path_buf(),
106 utf32: Utf32String::from(s.as_str()),
107 });
108 }
109
110 FileIndex { files, trigrams }
111 }
112
113 pub fn build(root: &Path) -> Self {
115 Self::build_with_max_depth(root, None)
116 }
117
118 #[allow(dead_code)]
121 pub fn from_paths(paths: Vec<PathBuf>) -> Self {
122 let mut files = Vec::with_capacity(paths.len());
123 let mut trigrams: HashMap<[u8; 3], Vec<usize>> = HashMap::new();
124
125 for path in paths {
126 let s = path.to_string_lossy().replace('\\', "/");
127 let idx = files.len();
128 index_trigrams(s.as_bytes(), idx, &mut trigrams);
129 files.push(FileEntry {
130 utf32: Utf32String::from(s.as_str()),
131 path,
132 });
133 }
134
135 FileIndex { files, trigrams }
136 }
137
138 #[allow(dead_code)]
139 pub fn files(&self) -> &[FileEntry] {
140 &self.files
141 }
142
143 fn trigram_candidate_indices(&self, query: &str) -> Option<Vec<usize>> {
149 let q: Vec<u8> = query.bytes().filter(|&b| b != b' ').collect();
153 if q.len() < 3 {
154 return None;
155 }
156
157 let first = [
158 q[0].to_ascii_lowercase(),
159 q[1].to_ascii_lowercase(),
160 q[2].to_ascii_lowercase(),
161 ];
162
163 let a = self.trigrams.get(&first)?;
164
165 if q.len() < 6 {
167 return Some(a.clone());
168 }
169
170 let last = [
171 q[q.len() - 3].to_ascii_lowercase(),
172 q[q.len() - 2].to_ascii_lowercase(),
173 q[q.len() - 1].to_ascii_lowercase(),
174 ];
175
176 let b = match self.trigrams.get(&last) {
177 Some(v) => v,
178 None => return Some(vec![]),
179 };
180
181 let mut out = Vec::with_capacity(a.len().min(b.len()));
183 let set: std::collections::HashSet<_> = b.iter().copied().collect();
184
185 for &idx in a {
186 if set.contains(&idx) {
187 out.push(idx);
188 }
189 }
190
191 Some(out)
192 }
193}
194
195fn index_trigrams(bytes: &[u8], idx: usize, map: &mut HashMap<[u8; 3], Vec<usize>>) {
197 use std::collections::HashSet;
198 let mut seen = HashSet::new();
199 for tri in bytes.windows(3) {
200 let key = [
201 tri[0].to_ascii_lowercase(),
202 tri[1].to_ascii_lowercase(),
203 tri[2].to_ascii_lowercase(),
204 ];
205 if seen.insert(key) {
206 map.entry(key).or_default().push(idx);
207 }
208 }
209}
210
211pub type SharedFileIndex = Arc<ArcSwap<Option<FileIndex>>>;
218
219pub fn spawn_indexer(root: PathBuf) -> SharedFileIndex {
224 let shared: SharedFileIndex = Arc::new(ArcSwap::from_pointee(None));
225 let out = shared.clone();
226 tokio::task::spawn_blocking(move || {
227 let idx = FileIndex::build(&root);
228 log::info!("file index built ({} files)", idx.files.len());
229 out.store(Arc::new(Some(idx)));
230 });
231 shared
232}
233
234fn should_trigger_notify_event(kind: ¬ify::EventKind) -> bool {
235 match kind {
236 notify::EventKind::Create(_) => true,
237 notify::EventKind::Remove(_) => true,
238 notify::EventKind::Modify(mod_kind) => {
239 matches!(mod_kind, notify::event::ModifyKind::Name(_))
241 }
242 _ => false,
243 }
244}
245
246#[cfg(test)]
253fn is_path_relevant(root: &Path, path: &Path) -> bool {
254 if let Ok(_rel) = path.strip_prefix(root) {
257 let mut gbuilder = GitignoreBuilder::new(root);
262
263 let mut walker = WalkBuilder::new(root);
264 walker.hidden(false).git_ignore(false).git_exclude(false);
265
266 for result in walker.build() {
267 if let Ok(entry) = result
268 && let Some(name_os) = entry.path().file_name()
269 && let Some(name) = name_os.to_str()
270 && name == ".gitignore" {
271 let _ = gbuilder.add(entry.path());
272 }
273 }
274
275 let git_info_exclude = root.join(".git").join("info").join("exclude");
277 if git_info_exclude.is_file() {
278 let _ = gbuilder.add(git_info_exclude);
279 }
280
281 let gi = match gbuilder.build() {
282 Ok(g) => g,
283 Err(_) => ignore::gitignore::Gitignore::empty(),
284 };
285
286 let is_dir = match path.metadata() {
287 Ok(md) => md.is_dir(),
288 Err(_) => path.extension().is_none(),
289 };
290
291 !gi.matched(path, is_dir).is_ignore()
292 } else {
293 true
294 }
295}
296
297#[cfg(test)]
299fn is_event_relevant(root: &Path, event: ¬ify::Event) -> bool {
300 event.paths.iter().any(|p| is_path_relevant(root, p))
301}
302
303pub fn spawn_watcher(root: PathBuf, index: SharedFileIndex) {
311 let (trigger_tx, mut trigger_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
312
313 {
318 let root = root.clone();
319 let trigger_tx_clone = trigger_tx.clone();
320
321 fn build_gitignore_for_root(root: &Path) -> ignore::gitignore::Gitignore {
325 let mut gbuilder = GitignoreBuilder::new(root);
326 let mut walker = WalkBuilder::new(root);
327 walker.hidden(false).git_ignore(false).git_exclude(false);
328 for entry in walker.build().filter_map(|r| r.ok()) {
329 if entry.path().file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
330 let _ = gbuilder.add(entry.path());
331 }
332 }
333 let git_info_exclude = root.join(".git").join("info").join("exclude");
335 if git_info_exclude.is_file() {
336 let _ = gbuilder.add(git_info_exclude);
337 }
338 match gbuilder.build() {
339 Ok(g) => g,
340 Err(_) => ignore::gitignore::Gitignore::empty(),
341 }
342 }
343
344 let cached_gi = std::sync::Arc::new(std::sync::Mutex::new(build_gitignore_for_root(&root)));
345
346 std::thread::spawn(move || {
347 let watcher_root = root.clone();
352 let cached_gi = cached_gi.clone();
353 let mut watcher = match notify::RecommendedWatcher::new(
354 move |res: Result<notify::Event, notify::Error>| {
355 match res {
356 Ok(event) => {
357 let mut rebuild = false;
359 for p in &event.paths {
360 if p.file_name().and_then(|n| n.to_str()) == Some(".gitignore") {
361 rebuild = true;
362 break;
363 }
364 if p.to_string_lossy().ends_with(".git/info/exclude") {
365 rebuild = true;
366 break;
367 }
368 }
369 if rebuild {
370 let new_gi = build_gitignore_for_root(&watcher_root);
371 if let Ok(mut guard) = cached_gi.lock() {
372 *guard = new_gi;
373 }
374 }
375
376 let mut any_relevant = false;
378 for p in &event.paths {
379 let is_dir = match p.metadata() {
380 Ok(md) => md.is_dir(),
381 Err(_) => p.extension().is_none(),
382 };
383 let guard = cached_gi.lock().unwrap();
384 if !guard.matched(p, is_dir).is_ignore() {
385 any_relevant = true;
386 break;
387 }
388 }
389 if !any_relevant {
390 return;
391 }
392
393 if should_trigger_notify_event(&event.kind) {
394 let _ = trigger_tx_clone.send(());
396 }
397 }
398 Err(e) => log::warn!("file watcher error: {e}"),
399 }
400 },
401 notify::Config::default(),
402 ) {
403 Ok(w) => w,
404 Err(e) => {
405 log::warn!("file watcher: failed to create watcher: {e}");
406 return;
407 }
408 };
409
410 if let Err(e) = watcher.watch(&root, notify::RecursiveMode::Recursive) {
411 log::warn!("file watcher: failed to watch {root:?}: {e}");
412 return;
413 }
414
415 let (_tx_keepalive, rx_keepalive) = std::sync::mpsc::channel::<()>();
418 let _ = rx_keepalive.recv();
419 });
420 }
421
422 tokio::spawn(async move {
427 let mut rebuild: Option<tokio::task::JoinHandle<()>> = None;
428 while trigger_rx.recv().await.is_some() {
429 while trigger_rx.try_recv().is_ok() {}
431
432 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
435
436 while trigger_rx.try_recv().is_ok() {}
438
439 if let Some(h) = rebuild.take() {
442 h.abort();
443 }
444 let idx = index.clone();
445 let r = root.clone();
446 rebuild = Some(tokio::task::spawn_blocking(move || {
447 idx.store(Arc::new(Some(FileIndex::build(&r))));
448 }));
449 }
450 });
451}
452
453pub struct NucleoSearch {
460 matcher: Matcher,
461}
462
463impl std::fmt::Debug for NucleoSearch {
464 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465 f.write_str("NucleoSearch")
466 }
467}
468
469impl Default for NucleoSearch {
470 fn default() -> Self {
471 Self::new()
472 }
473}
474
475impl NucleoSearch {
476 pub fn new() -> Self {
477 Self {
478 matcher: Matcher::new(Config::DEFAULT),
479 }
480 }
481
482 pub fn search_top<'a>(
489 &mut self,
490 index: &'a FileIndex,
491 query: &str,
492 max: usize,
493 ) -> Vec<&'a FileEntry> {
494 if query.is_empty() {
495 return index.files.iter().take(max).collect();
496 }
497
498 let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
499
500 let mut heap: BinaryHeap<(Reverse<u32>, usize)> = BinaryHeap::with_capacity(max);
501
502 let mut process_candidate = |fi: usize| {
504 let entry = &index.files[fi];
505
506 let Some(score) = pattern.score(entry.utf32.slice(..), &mut self.matcher) else {
507 return;
508 };
509
510 if heap.len() < max {
511 heap.push((Reverse(score), fi));
512 } else if let Some(&(Reverse(min_score), _)) = heap.peek()
513 && score > min_score {
514 heap.pop();
515 heap.push((Reverse(score), fi));
516 }
517 };
518
519 if let Some(indices) = index.trigram_candidate_indices(query) {
521 for fi in indices {
522 process_candidate(fi);
523 }
524 } else {
525 for fi in 0..index.files.len() {
526 process_candidate(fi);
527 }
528 }
529
530 let mut results: Vec<(u32, usize)> = heap
532 .into_iter()
533 .map(|(Reverse(score), idx)| (score, idx))
534 .collect();
535
536 results.sort_unstable_by_key(|b| std::cmp::Reverse(b.0));
537
538 results
539 .into_iter()
540 .map(|(_, idx)| &index.files[idx])
541 .collect()
542 }
543
544 #[allow(dead_code)]
546 pub fn search<'a>(&mut self, index: &'a FileIndex, query: &str) -> Vec<&'a FileEntry> {
547 self.search_top(index, query, usize::MAX)
548 }
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use notify::event::{CreateKind, DataChange, ModifyKind, RenameMode};
555 use notify::Event;
556 use std::path::PathBuf;
557 use tempfile::tempdir;
558 use std::fs::{create_dir_all, write};
559
560 #[test]
561 fn should_trigger_on_create() {
562 let ev = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![PathBuf::from("a")], attrs: Default::default() };
563 assert!(should_trigger_notify_event(&ev.kind));
564 }
565
566 #[test]
567 fn should_ignore_modify_data() {
568 let ev = Event { kind: notify::EventKind::Modify(ModifyKind::Data(DataChange::Content)), paths: vec![PathBuf::from("a")], attrs: Default::default() };
569 assert!(!should_trigger_notify_event(&ev.kind));
570 }
571
572 #[test]
573 fn should_trigger_rename() {
574 let ev = Event { kind: notify::EventKind::Modify(ModifyKind::Name(RenameMode::Both)), paths: vec![PathBuf::from("a")], attrs: Default::default() };
575 assert!(should_trigger_notify_event(&ev.kind));
576 }
577
578 #[test]
579 fn shallow_build_limits_depth() {
580 let td = tempdir().unwrap();
581 let root = td.path();
582 create_dir_all(root.join("a/b")).unwrap();
583 write(root.join("file1.txt"), b"").unwrap();
584 write(root.join("a").join("file2.txt"), b"").unwrap();
585 write(root.join("a").join("b").join("file3.txt"), b"").unwrap();
586
587 let idx_full = FileIndex::build_with_max_depth(root, None);
588 let paths_full: Vec<String> = idx_full.files.iter().map(|e| e.path.to_string_lossy().replace('\\', "/").to_string()).collect();
589 assert!(paths_full.iter().any(|p| p == "file1.txt"));
590 assert!(paths_full.iter().any(|p| p == "a/file2.txt"));
591 assert!(paths_full.iter().any(|p| p == "a/b/file3.txt"));
592
593 let idx_shallow = FileIndex::build_with_max_depth(root, Some(1));
594 let paths_shallow: Vec<String> = idx_shallow.files.iter().map(|e| e.path.to_string_lossy().replace('\\', "/").to_string()).collect();
595 assert!(paths_shallow.iter().any(|p| p == "file1.txt"));
596 assert!(!paths_shallow.iter().any(|p| p == "a/file2.txt"));
597 assert!(!paths_shallow.iter().any(|p| p == "a/b/file3.txt"));
598 }
599
600 #[test]
601 fn walkbuilder_respects_gitignore() {
602 let td = tempdir().unwrap();
603 let root = td.path();
604 write(root.join(".gitignore"), b"ignored.txt\n").unwrap();
606 write(root.join("ignored.txt"), b"").unwrap();
607 write(root.join("not_ignored.txt"), b"").unwrap();
608
609 let ev_ignored = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![root.join("ignored.txt")], attrs: Default::default() };
611 assert!(!is_event_relevant(root, &ev_ignored));
612
613 let ev_not = Event { kind: notify::EventKind::Create(CreateKind::File), paths: vec![root.join("not_ignored.txt")], attrs: Default::default() };
615 assert!(is_event_relevant(root, &ev_not));
616 }
617}