1use std::collections::{HashMap, HashSet};
34use std::path::{Path, PathBuf};
35use std::sync::{Arc, Mutex, OnceLock};
36use std::time::{Duration, Instant};
37
38use glob::Pattern;
39use ignore::WalkBuilder;
40
41use crate::tools::ctx_search::{MAX_FILE_SIZE, MAX_WALK_DEPTH, is_binary_ext, is_generated_file};
42
43const MAX_FILES: usize = 200_000;
45
46const MAX_POSTING_ENTRIES: usize = 12_000_000;
51
52const MAX_TOTAL_ENTRIES: usize = 48_000_000;
56
57const BLOOM_BITS_PER_ITEM: usize = 12;
61const BLOOM_K: usize = 7;
62const BLOOM_MIN_BITS: usize = 64;
64const BLOOM_MAX_BITS: usize = 1 << 20;
65
66fn is_word_byte(b: u8) -> bool {
68 b.is_ascii_alphanumeric() || b == b'_'
69}
70
71fn pack(b0: u8, b1: u8, b2: u8) -> u32 {
72 (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2)
73}
74
75enum Narrowing {
84 Postings(HashMap<u32, Vec<u32>>),
85 Blooms(Vec<FileBloom>),
86}
87
88struct FileBloom {
91 bits: Vec<u64>,
93}
94
95#[inline]
98fn mix64(mut x: u64) -> u64 {
99 x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
100 x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
101 x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
102 x ^ (x >> 31)
103}
104
105impl FileBloom {
106 fn with_capacity(distinct_trigrams: usize) -> Self {
107 let target = distinct_trigrams
108 .saturating_mul(BLOOM_BITS_PER_ITEM)
109 .next_power_of_two()
110 .clamp(BLOOM_MIN_BITS, BLOOM_MAX_BITS);
111 FileBloom {
112 bits: vec![0u64; target / 64],
113 }
114 }
115
116 #[inline]
117 fn m_bits(&self) -> usize {
118 self.bits.len() * 64
119 }
120
121 #[inline]
123 fn probes(&self, trigram: u32) -> impl Iterator<Item = usize> + '_ {
124 let m = self.m_bits();
125 let mask = m - 1; let h = mix64(u64::from(trigram));
127 let h1 = (h & 0xFFFF_FFFF) as usize;
128 let h2 = ((h >> 32) as usize) | 1; (0..BLOOM_K).map(move |i| h1.wrapping_add(i.wrapping_mul(h2)) & mask)
130 }
131
132 fn insert(&mut self, trigram: u32) {
133 for p in self.probes(trigram).collect::<Vec<_>>() {
134 self.bits[p / 64] |= 1u64 << (p % 64);
135 }
136 }
137
138 fn maybe_contains(&self, trigram: u32) -> bool {
139 self.probes(trigram)
140 .all(|p| self.bits[p / 64] & (1u64 << (p % 64)) != 0)
141 }
142}
143
144pub struct SearchIndex {
146 files: Vec<PathBuf>,
147 narrowing: Narrowing,
149 respect_gitignore: bool,
150 allow_secret_paths: bool,
151 signature: u64,
154}
155
156impl SearchIndex {
157 pub fn build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> Option<Self> {
160 let mut files: Vec<PathBuf> = Vec::new();
161 let mut per_file_trigrams: Vec<Vec<u32>> = Vec::new();
165 let mut total_entries: usize = 0;
166 let mut scratch: HashSet<u32> = HashSet::new();
167 let mut sig_sum: u64 = 0;
174 let mut file_count: usize = 0;
175 let mut aborted = false;
176
177 walk_index_corpus(
178 root,
179 respect_gitignore,
180 allow_secret_paths,
181 |path, state| {
182 sig_sum = sig_sum.wrapping_add(file_sig(path, state));
183 file_count += 1;
184
185 let content: std::sync::Arc<str> = if let Some(cached) =
192 state.and_then(|s| crate::core::content_cache::get(path, s))
193 {
194 cached
195 } else {
196 let Ok(text) = std::fs::read_to_string(path) else {
197 return true;
198 };
199 let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
200 if let Some(s) = state {
201 crate::core::content_cache::insert(path, s, std::sync::Arc::clone(&arc));
202 }
203 arc
204 };
205
206 if files.len() >= MAX_FILES {
207 aborted = true; return false;
209 }
210
211 scratch.clear();
212 let bytes = content.as_bytes();
213 if bytes.len() >= 3 {
214 for w in bytes.windows(3) {
215 if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
216 scratch.insert(pack(w[0], w[1], w[2]));
217 }
218 }
219 }
220 total_entries += scratch.len();
221 if total_entries > MAX_TOTAL_ENTRIES {
222 aborted = true; return false;
224 }
225 let mut tris: Vec<u32> = scratch.iter().copied().collect();
226 tris.sort_unstable();
227 files.push(path.to_path_buf());
228 per_file_trigrams.push(tris);
229 true
230 },
231 )?;
232
233 if aborted {
234 return None;
235 }
236
237 let narrowing = build_narrowing(&per_file_trigrams, total_entries);
238
239 Some(Self {
240 files,
241 narrowing,
242 respect_gitignore,
243 allow_secret_paths,
244 signature: finalize_sig(sig_sum, file_count),
245 })
246 }
247
248 fn config_matches(&self, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
249 self.respect_gitignore == respect_gitignore && self.allow_secret_paths == allow_secret_paths
250 }
251
252 pub fn candidate_paths(
262 &self,
263 pattern: &str,
264 includes: &[Pattern],
265 root: &Path,
266 ) -> CandidateSet {
267 if let Some(ids) = self.literal_candidates(pattern) {
268 let paths = ids
269 .into_iter()
270 .map(|id| self.files[id as usize].clone())
271 .filter(|p| glob_matches(p, includes, root))
272 .collect();
273 CandidateSet::Narrowed(paths)
274 } else {
275 let paths = self
276 .files
277 .iter()
278 .filter(|p| glob_matches(p, includes, root))
279 .cloned()
280 .collect();
281 CandidateSet::FullList(paths)
282 }
283 }
284
285 fn literal_candidates(&self, pattern: &str) -> Option<Vec<u32>> {
289 let bytes = pattern.as_bytes();
290 if bytes.len() < 3 || !bytes.iter().all(|&b| is_word_byte(b)) {
291 return None;
292 }
293 let mut tris: Vec<u32> = bytes.windows(3).map(|w| pack(w[0], w[1], w[2])).collect();
295 tris.sort_unstable();
296 tris.dedup();
297
298 match &self.narrowing {
299 Narrowing::Postings(trigrams) => Some(Self::postings_intersect(trigrams, &tris)),
300 Narrowing::Blooms(blooms) => Some(Self::bloom_scan(blooms, &tris)),
301 }
302 }
303
304 fn postings_intersect(trigrams: &HashMap<u32, Vec<u32>>, tris: &[u32]) -> Vec<u32> {
307 let mut lists: Vec<&Vec<u32>> = Vec::with_capacity(tris.len());
308 for &tri in tris {
309 match trigrams.get(&tri) {
310 None => return Vec::new(),
312 Some(list) => lists.push(list),
313 }
314 }
315 lists.sort_by_key(|l| l.len());
316
317 let mut acc: Vec<u32> = lists[0].clone();
318 for list in &lists[1..] {
319 acc = intersect_sorted(&acc, list);
320 if acc.is_empty() {
321 break;
322 }
323 }
324 acc
325 }
326
327 fn bloom_scan(blooms: &[FileBloom], tris: &[u32]) -> Vec<u32> {
331 let mut out = Vec::new();
332 for (fid, bloom) in blooms.iter().enumerate() {
333 if tris.iter().all(|&t| bloom.maybe_contains(t)) {
334 out.push(fid as u32);
335 }
336 }
337 out
338 }
339}
340
341fn build_narrowing(per_file: &[Vec<u32>], total_entries: usize) -> Narrowing {
343 if total_entries <= MAX_POSTING_ENTRIES {
344 let mut trigrams: HashMap<u32, Vec<u32>> = HashMap::new();
345 for (fid, tris) in per_file.iter().enumerate() {
346 for &t in tris {
347 trigrams.entry(t).or_default().push(fid as u32);
349 }
350 }
351 Narrowing::Postings(trigrams)
352 } else {
353 let blooms = per_file
354 .iter()
355 .map(|tris| {
356 let mut b = FileBloom::with_capacity(tris.len());
357 for &t in tris {
358 b.insert(t);
359 }
360 b
361 })
362 .collect();
363 Narrowing::Blooms(blooms)
364 }
365}
366
367pub enum CandidateSet {
369 Narrowed(Vec<PathBuf>),
371 FullList(Vec<PathBuf>),
373}
374
375impl CandidateSet {
376 pub fn into_paths(self) -> Vec<PathBuf> {
377 match self {
378 CandidateSet::Narrowed(p) | CandidateSet::FullList(p) => p,
379 }
380 }
381}
382
383fn glob_matches(path: &Path, includes: &[Pattern], root: &Path) -> bool {
386 if includes.is_empty() {
387 return true;
388 }
389 let rel = path.strip_prefix(root).unwrap_or(path);
390 let rel_str = rel.to_string_lossy();
391 includes.iter().any(|p| p.matches(&rel_str))
392}
393
394fn intersect_sorted(a: &[u32], b: &[u32]) -> Vec<u32> {
396 let mut out = Vec::new();
397 let (mut i, mut j) = (0, 0);
398 while i < a.len() && j < b.len() {
399 match a[i].cmp(&b[j]) {
400 std::cmp::Ordering::Less => i += 1,
401 std::cmp::Ordering::Greater => j += 1,
402 std::cmp::Ordering::Equal => {
403 out.push(a[i]);
404 i += 1;
405 j += 1;
406 }
407 }
408 }
409 out
410}
411
412fn walk_index_corpus<F>(
427 root: &str,
428 respect_gitignore: bool,
429 allow_secret_paths: bool,
430 mut visit: F,
431) -> Option<()>
432where
433 F: FnMut(&Path, Option<crate::core::content_cache::FileState>) -> bool,
434{
435 let root_path = Path::new(root);
436 if !root_path.exists() {
437 return None;
438 }
439 if !crate::core::graph_index::is_safe_scan_root_public(root) {
444 return None;
445 }
446
447 let walker = WalkBuilder::new(root_path)
448 .hidden(true)
449 .max_depth(Some(MAX_WALK_DEPTH))
450 .git_ignore(respect_gitignore)
451 .git_global(respect_gitignore)
452 .git_exclude(respect_gitignore)
453 .require_git(false)
454 .filter_entry(crate::core::walk_filter::keep_entry)
455 .build();
456
457 for entry in walker.filter_map(std::result::Result::ok) {
458 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
459 continue;
460 }
461 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
462 continue;
463 }
464 let path = entry.path();
465 if is_binary_ext(path) || is_generated_file(path) {
466 continue;
467 }
468 if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
469 continue;
470 }
471 let state = match std::fs::metadata(path) {
474 Ok(meta) if !meta.file_type().is_file() => continue,
475 Ok(meta) if meta.len() > MAX_FILE_SIZE => continue,
476 Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
477 Err(_) => continue,
478 };
479 if !visit(path, state) {
480 break;
481 }
482 }
483 Some(())
484}
485
486fn file_sig(path: &Path, state: Option<crate::core::content_cache::FileState>) -> u64 {
493 let mut h: u64 = 0xcbf2_9ce4_8422_2325; for &b in path.as_os_str().as_encoded_bytes() {
495 h ^= u64::from(b);
496 h = h.wrapping_mul(0x0000_0100_0000_01b3); }
498 if let Some(st) = state {
499 h ^= mix64(st.mtime_ms).rotate_left(1);
500 h ^= mix64(st.size_bytes).rotate_left(33);
501 }
502 mix64(h)
503}
504
505fn finalize_sig(sum: u64, count: usize) -> u64 {
509 sum ^ mix64(count as u64).rotate_left(32)
510}
511
512fn corpus_signature(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> Option<u64> {
519 let mut sum: u64 = 0;
520 let mut count: usize = 0;
521 walk_index_corpus(
522 root,
523 respect_gitignore,
524 allow_secret_paths,
525 |path, state| {
526 sum = sum.wrapping_add(file_sig(path, state));
527 count += 1;
528 true
529 },
530 )?;
531 Some(finalize_sig(sum, count))
532}
533
534struct CacheEntry {
539 index: Option<Arc<SearchIndex>>,
540 building: bool,
541 last_verified: Option<Instant>,
545}
546
547impl CacheEntry {
548 fn empty() -> Self {
549 Self {
550 index: None,
551 building: false,
552 last_verified: None,
553 }
554 }
555}
556
557static CACHE: OnceLock<Mutex<HashMap<String, CacheEntry>>> = OnceLock::new();
558
559fn cache() -> &'static Mutex<HashMap<String, CacheEntry>> {
560 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
561}
562
563fn index_disabled() -> bool {
566 std::env::var("LEAN_CTX_DISABLE_SEARCH_INDEX")
567 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
568}
569
570fn coalesce_window() -> Option<Duration> {
576 let ms = std::env::var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS")
577 .ok()
578 .and_then(|v| v.trim().parse::<u64>().ok())
579 .unwrap_or(0);
580 (ms > 0).then(|| Duration::from_millis(ms))
581}
582
583pub fn get_fresh(
587 root: &str,
588 respect_gitignore: bool,
589 allow_secret_paths: bool,
590) -> Option<Arc<SearchIndex>> {
591 if !respect_gitignore || index_disabled() {
593 return None;
594 }
595
596 let (candidate, last_verified) = {
599 let map = cache()
600 .lock()
601 .unwrap_or_else(std::sync::PoisonError::into_inner);
602 match map.get(root) {
603 Some(entry) => match &entry.index {
604 Some(idx) if idx.config_matches(respect_gitignore, allow_secret_paths) => {
605 (Some(Arc::clone(idx)), entry.last_verified)
606 }
607 _ => (None, None),
608 },
609 None => (None, None),
610 }
611 };
612
613 let Some(idx) = candidate else {
614 request_build(root, respect_gitignore, allow_secret_paths);
616 return None;
617 };
618
619 if let Some(window) = coalesce_window()
622 && last_verified.is_some_and(|t| t.elapsed() < window)
623 {
624 return Some(idx);
625 }
626
627 match corpus_signature(root, respect_gitignore, allow_secret_paths) {
631 Some(sig) if sig == idx.signature => {
632 mark_verified(root);
633 Some(idx)
634 }
635 _ => {
636 request_build(root, respect_gitignore, allow_secret_paths);
639 None
640 }
641 }
642}
643
644pub fn clear_resident() {
650 let mut map = cache()
651 .lock()
652 .unwrap_or_else(std::sync::PoisonError::into_inner);
653 for entry in map.values_mut() {
654 entry.index = None;
655 entry.last_verified = None;
656 }
657}
658
659fn mark_verified(root: &str) {
662 let mut map = cache()
663 .lock()
664 .unwrap_or_else(std::sync::PoisonError::into_inner);
665 if let Some(entry) = map.get_mut(root) {
666 entry.last_verified = Some(Instant::now());
667 }
668}
669
670fn request_build(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
672 let needs_build = {
673 let mut map = cache()
674 .lock()
675 .unwrap_or_else(std::sync::PoisonError::into_inner);
676 let entry = map
677 .entry(root.to_string())
678 .or_insert_with(CacheEntry::empty);
679 if entry.building {
680 false
681 } else {
682 entry.building = true;
683 true
684 }
685 };
686 if needs_build {
687 spawn_build(root.to_string(), respect_gitignore, allow_secret_paths);
688 }
689}
690
691pub fn ensure_background(root: &str, respect_gitignore: bool, allow_secret_paths: bool) {
694 if !respect_gitignore || index_disabled() {
695 return;
696 }
697 let has_index = {
701 let map = cache()
702 .lock()
703 .unwrap_or_else(std::sync::PoisonError::into_inner);
704 map.get(root).is_some_and(|entry| {
705 entry
706 .index
707 .as_ref()
708 .is_some_and(|idx| idx.config_matches(respect_gitignore, allow_secret_paths))
709 })
710 };
711 if !has_index {
712 request_build(root, respect_gitignore, allow_secret_paths);
713 }
714}
715
716pub fn warm_blocking(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> bool {
720 if !respect_gitignore || index_disabled() {
721 return false;
722 }
723 let Some(idx) = SearchIndex::build(root, respect_gitignore, allow_secret_paths) else {
724 return false;
725 };
726 let mut map = cache()
727 .lock()
728 .unwrap_or_else(std::sync::PoisonError::into_inner);
729 map.insert(
730 root.to_string(),
731 CacheEntry {
732 index: Some(Arc::new(idx)),
733 building: false,
734 last_verified: Some(Instant::now()),
735 },
736 );
737 true
738}
739
740fn search_index_lock_name(root: &str) -> String {
745 format!(
746 "search-idx-{}",
747 &crate::core::index_namespace::namespace_hash(Path::new(root))[..8]
748 )
749}
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
754enum BuildOutcome {
755 Built,
756 Deferred,
757}
758
759fn build_guarded(root: &str, respect_gitignore: bool, allow_secret_paths: bool) -> BuildOutcome {
769 let lock = crate::core::startup_guard::try_acquire_lock(
770 &search_index_lock_name(root),
771 Duration::from_millis(200),
772 Duration::from_mins(3),
773 );
774 if lock.is_none() {
775 let mut map = cache()
778 .lock()
779 .unwrap_or_else(std::sync::PoisonError::into_inner);
780 if let Some(entry) = map.get_mut(root) {
781 entry.building = false;
782 }
783 return BuildOutcome::Deferred;
784 }
785
786 let built = std::panic::catch_unwind(|| {
787 SearchIndex::build(root, respect_gitignore, allow_secret_paths)
788 })
789 .ok()
790 .flatten();
791
792 let mut map = cache()
793 .lock()
794 .unwrap_or_else(std::sync::PoisonError::into_inner);
795 if let Some(entry) = map.get_mut(root) {
796 entry.building = false;
797 if let Some(idx) = built {
798 entry.index = Some(Arc::new(idx));
799 entry.last_verified = Some(Instant::now());
801 }
802 }
803 drop(lock);
805 BuildOutcome::Built
806}
807
808fn spawn_build(root: String, respect_gitignore: bool, allow_secret_paths: bool) {
809 std::thread::spawn(move || {
810 let _ = build_guarded(&root, respect_gitignore, allow_secret_paths);
811 });
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 fn corpus() -> tempfile::TempDir {
819 let dir = tempfile::tempdir().unwrap();
820 std::fs::write(dir.path().join("a.rs"), "fn handler() {}\nlet x = 1;\n").unwrap();
821 std::fs::write(dir.path().join("b.rs"), "fn other() {}\n// nothing here\n").unwrap();
822 std::fs::write(dir.path().join("c.txt"), "handler appears in text too\n").unwrap();
823 dir
824 }
825
826 #[test]
827 fn build_refuses_to_index_home_directory() {
828 if let Some(home) = dirs::home_dir() {
831 assert!(
832 SearchIndex::build(&home.to_string_lossy(), true, false).is_none(),
833 "search index must never auto-build over the home directory"
834 );
835 }
836 }
837
838 #[test]
839 fn narrows_to_files_containing_literal() {
840 let dir = corpus();
841 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
842 let cands = idx.candidate_paths("handler", &[], dir.path());
843 let paths = cands.into_paths();
844 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
846 assert!(paths.iter().any(|p| p.ends_with("c.txt")));
847 assert!(!paths.iter().any(|p| p.ends_with("b.rs")));
848 }
849
850 #[test]
851 fn absent_trigram_yields_empty_candidates() {
852 let dir = corpus();
853 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
854 match idx.candidate_paths("zzzqqq", &[], dir.path()) {
855 CandidateSet::Narrowed(p) => assert!(p.is_empty()),
856 CandidateSet::FullList(_) => panic!("pure literal should narrow"),
857 }
858 }
859
860 #[test]
861 fn ext_filter_restricts_candidates() {
862 let dir = corpus();
863 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
864 let paths = idx
865 .candidate_paths(
866 "handler",
867 &[glob::Pattern::new("*.rs").unwrap()],
868 dir.path(),
869 )
870 .into_paths();
871 assert!(paths.iter().all(|p| p.extension().unwrap() == "rs"));
872 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
873 }
874
875 #[test]
876 #[cfg(unix)]
877 fn build_skips_named_pipe_without_hanging() {
878 use std::sync::mpsc;
879 use std::time::Duration;
880 let dir = corpus();
884 let fifo = dir.path().join("pipe.fifo");
885 let c = std::ffi::CString::new(fifo.to_string_lossy().as_bytes()).unwrap();
886 assert_eq!(
887 unsafe { libc::mkfifo(c.as_ptr(), 0o644) },
890 0,
891 "mkfifo failed"
892 );
893
894 let root = dir.path().to_str().unwrap().to_string();
895 let (tx, rx) = mpsc::channel();
896 std::thread::spawn(move || {
897 let built = SearchIndex::build(&root, true, false);
898 let _ = tx.send(built.map(|idx| {
899 idx.candidate_paths("handler", &[], std::path::Path::new(&root))
900 .into_paths()
901 }));
902 });
903 let paths = rx
904 .recv_timeout(Duration::from_secs(5))
905 .expect("SearchIndex::build hung on a FIFO (#336 regression)")
906 .expect("index should build");
907 assert!(paths.iter().any(|p| p.ends_with("a.rs")));
908 assert!(!paths.iter().any(|p| p.ends_with("pipe.fifo")));
909 }
910
911 #[test]
912 fn regex_query_falls_back_to_full_list() {
913 let dir = corpus();
914 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
915 match idx.candidate_paths("fn .*\\(\\)", &[], dir.path()) {
916 CandidateSet::FullList(p) => assert!(!p.is_empty()),
917 CandidateSet::Narrowed(_) => panic!("metachar query must not narrow"),
918 }
919 }
920
921 #[test]
922 fn short_query_falls_back_to_full_list() {
923 let dir = corpus();
924 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
925 assert!(matches!(
926 idx.candidate_paths("fn", &[], dir.path()),
927 CandidateSet::FullList(_)
928 ));
929 }
930
931 #[test]
935 fn narrowing_has_identical_recall_to_full_scan() {
936 use regex::Regex;
937 use std::collections::BTreeSet;
938
939 let dir = tempfile::tempdir().unwrap();
940 let samples = [
942 (
943 "auth/login.rs",
944 "fn authenticate(user) {}\nlet token = mint();\n",
945 ),
946 (
947 "auth/session.rs",
948 "struct Session;\n// authenticate again here\n",
949 ),
950 ("db/pool.rs", "fn connect() {}\nlet retries = 3;\n"),
951 (
952 "ui/button.tsx",
953 "export const Button = () => authenticate;\n",
954 ),
955 ("readme.md", "This project uses authenticate flows.\n"),
956 ("unrelated.rs", "fn helper() { let v = 1; }\n"),
957 ];
958 for (rel, content) in samples {
959 let p = dir.path().join(rel);
960 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
961 std::fs::write(p, content).unwrap();
962 }
963 let idx = SearchIndex::build(dir.path().to_str().unwrap(), true, false).unwrap();
964
965 let full_scan = |pat: &str| -> BTreeSet<String> {
966 let re = Regex::new(pat).unwrap();
967 let mut hits = BTreeSet::new();
968 for (rel, content) in samples {
969 for (i, line) in content.lines().enumerate() {
970 if re.is_match(line) {
971 hits.insert(format!("{rel}:{}", i + 1));
972 }
973 }
974 }
975 hits
976 };
977
978 for query in ["authenticate", "Session", "retries", "token"] {
979 let re = Regex::new(query).unwrap();
980 let candidates = idx.candidate_paths(query, &[], dir.path()).into_paths();
981 let mut narrowed = BTreeSet::new();
982 for path in &candidates {
983 let content = std::fs::read_to_string(path).unwrap();
984 let rel = path.strip_prefix(dir.path()).unwrap().to_string_lossy();
985 for (i, line) in content.lines().enumerate() {
986 if re.is_match(line) {
987 narrowed.insert(format!("{}:{}", rel.replace('\\', "/"), i + 1));
988 }
989 }
990 }
991 assert_eq!(
992 narrowed,
993 full_scan(query),
994 "recall mismatch for query {query:?}"
995 );
996 }
997 }
998
999 #[test]
1000 fn intersect_sorted_basic() {
1001 assert_eq!(
1002 intersect_sorted(&[1, 2, 3, 5], &[2, 3, 4, 5]),
1003 vec![2, 3, 5]
1004 );
1005 assert_eq!(intersect_sorted(&[1, 2], &[3, 4]), Vec::<u32>::new());
1006 }
1007
1008 fn trigrams_of(s: &str) -> Vec<u32> {
1011 let mut set = HashSet::new();
1012 let b = s.as_bytes();
1013 if b.len() >= 3 {
1014 for w in b.windows(3) {
1015 if is_word_byte(w[0]) && is_word_byte(w[1]) && is_word_byte(w[2]) {
1016 set.insert(pack(w[0], w[1], w[2]));
1017 }
1018 }
1019 }
1020 let mut v: Vec<u32> = set.into_iter().collect();
1021 v.sort_unstable();
1022 v
1023 }
1024
1025 #[test]
1026 fn file_bloom_has_no_false_negatives() {
1027 let tris = trigrams_of("fn authenticate(user) { let token = mint(); }");
1028 let mut bloom = FileBloom::with_capacity(tris.len());
1029 for &t in &tris {
1030 bloom.insert(t);
1031 }
1032 assert!(tris.iter().all(|&t| bloom.maybe_contains(t)));
1034 }
1035
1036 #[test]
1040 fn bloom_tier_is_superset_of_postings_tier() {
1041 let mut seed = 0x1234_5678_9abc_def0u64;
1043 let mut rng = || {
1044 seed = seed
1045 .wrapping_mul(6364136223846793005)
1046 .wrapping_add(1442695040888963407);
1047 (seed >> 33) as u32
1048 };
1049 let mut per_file: Vec<Vec<u32>> = Vec::new();
1050 for _ in 0..80 {
1051 let n = 50 + (rng() % 250) as usize;
1052 let mut s = HashSet::new();
1053 for _ in 0..n {
1054 s.insert(rng() & 0x00FF_FFFF);
1055 }
1056 let mut v: Vec<u32> = s.into_iter().collect();
1057 v.sort_unstable();
1058 per_file.push(v);
1059 }
1060 let total: usize = per_file.iter().map(Vec::len).sum();
1061
1062 let postings = build_narrowing(&per_file, total); let blooms = build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1); let (Narrowing::Postings(pt), Narrowing::Blooms(bl)) = (&postings, &blooms) else {
1065 panic!("unexpected narrowing tiers");
1066 };
1067
1068 for f in &per_file {
1071 if f.len() < 3 {
1072 continue;
1073 }
1074 let q = vec![f[0], f[f.len() / 2], f[f.len() - 1]];
1075 let exact = SearchIndex::postings_intersect(pt, &q);
1076 let bloom: HashSet<u32> = SearchIndex::bloom_scan(bl, &q).into_iter().collect();
1077 for id in exact {
1078 assert!(
1079 bloom.contains(&id),
1080 "Bloom tier dropped a true match (false negative) for {q:?}"
1081 );
1082 }
1083 }
1084 }
1085
1086 #[test]
1089 fn bloom_tier_end_to_end_recall() {
1090 let samples = [
1091 (
1092 "auth_login.rs",
1093 "fn authenticate(user) {}\nlet token = mint();\n",
1094 ),
1095 (
1096 "auth_session.rs",
1097 "struct Session;\n// authenticate again here\n",
1098 ),
1099 ("db_pool.rs", "fn connect() {}\nlet retries = 3;\n"),
1100 (
1101 "ui_button.tsx",
1102 "export const Button = () => authenticate;\n",
1103 ),
1104 ("readme.md", "This project uses authenticate flows.\n"),
1105 ("unrelated.rs", "fn helper() { let v = 1; }\n"),
1106 ];
1107 let files: Vec<PathBuf> = samples.iter().map(|(rel, _)| PathBuf::from(rel)).collect();
1108 let per_file: Vec<Vec<u32>> = samples.iter().map(|(_, c)| trigrams_of(c)).collect();
1109
1110 let idx = SearchIndex {
1111 files,
1112 narrowing: build_narrowing(&per_file, MAX_POSTING_ENTRIES + 1),
1113 respect_gitignore: true,
1114 allow_secret_paths: false,
1115 signature: 0, };
1117 assert!(
1118 matches!(idx.narrowing, Narrowing::Blooms(_)),
1119 "test must exercise the Bloom tier"
1120 );
1121
1122 for query in ["authenticate", "Session", "retries", "token"] {
1123 let cands: HashSet<String> = idx
1124 .candidate_paths(query, &[], std::path::Path::new(""))
1125 .into_paths()
1126 .iter()
1127 .map(|p| p.to_string_lossy().to_string())
1128 .collect();
1129 for (rel, content) in samples {
1130 if content.contains(query) {
1131 assert!(
1132 cands.contains(rel),
1133 "Bloom tier dropped real match {rel} for query {query:?}"
1134 );
1135 }
1136 }
1137 }
1138 }
1139
1140 struct DataDirGuard {
1143 prev: Option<String>,
1144 }
1145 impl DataDirGuard {
1146 fn set(path: &std::path::Path) -> Self {
1147 let prev = std::env::var("LEAN_CTX_DATA_DIR").ok();
1148 crate::test_env::set_var("LEAN_CTX_DATA_DIR", path);
1149 Self { prev }
1150 }
1151 }
1152 impl Drop for DataDirGuard {
1153 fn drop(&mut self) {
1154 match self.prev.as_deref() {
1155 Some(v) => crate::test_env::set_var("LEAN_CTX_DATA_DIR", v),
1156 None => crate::test_env::remove_var("LEAN_CTX_DATA_DIR"),
1157 }
1158 }
1159 }
1160
1161 #[test]
1162 fn search_index_lock_name_is_per_repo_and_distinct() {
1163 let a = search_index_lock_name("/tmp/repo-a");
1164 let b = search_index_lock_name("/tmp/repo-b");
1165 assert!(a.starts_with("search-idx-"), "unexpected lock name: {a}");
1166 assert_ne!(a, b, "lock name must be per-repo");
1167 assert_eq!(a, search_index_lock_name("/tmp/repo-a"), "stable per repo");
1168 let h = &crate::core::index_namespace::namespace_hash(Path::new("/tmp/repo-a"))[..8];
1171 assert_ne!(
1172 a,
1173 format!("graph-idx-{h}"),
1174 "must not collide with graph lock"
1175 );
1176 assert_ne!(
1177 a,
1178 format!("bm25-idx-{h}"),
1179 "must not collide with bm25 lock"
1180 );
1181 }
1182
1183 #[test]
1184 fn build_guarded_builds_when_uncontended() {
1185 let _env = crate::core::data_dir::test_env_lock();
1186 let data = tempfile::tempdir().unwrap();
1187 let _guard = DataDirGuard::set(data.path());
1188
1189 let dir = corpus();
1190 let root = dir.path().to_string_lossy().to_string();
1191 {
1193 let mut map = cache()
1194 .lock()
1195 .unwrap_or_else(std::sync::PoisonError::into_inner);
1196 map.insert(
1197 root.clone(),
1198 CacheEntry {
1199 index: None,
1200 building: true,
1201 last_verified: None,
1202 },
1203 );
1204 }
1205 assert_eq!(
1206 build_guarded(&root, true, false),
1207 BuildOutcome::Built,
1208 "an uncontended root must build"
1209 );
1210 let map = cache()
1211 .lock()
1212 .unwrap_or_else(std::sync::PoisonError::into_inner);
1213 let entry = map.get(&root).expect("entry present");
1214 assert!(!entry.building, "building flag must clear after build");
1215 assert!(entry.index.is_some(), "index must be installed after build");
1216 }
1217
1218 #[test]
1219 fn build_guarded_defers_when_another_process_holds_the_lock() {
1220 let _env = crate::core::data_dir::test_env_lock();
1221 let data = tempfile::tempdir().unwrap();
1222 let _guard = DataDirGuard::set(data.path());
1223
1224 let dir = corpus();
1225 let root = dir.path().to_string_lossy().to_string();
1226 let lock_path = data
1230 .path()
1231 .join(format!(".{}.lock", search_index_lock_name(&root)));
1232 std::fs::write(&lock_path, format!("{}\n", std::process::id())).unwrap();
1233
1234 {
1235 let mut map = cache()
1236 .lock()
1237 .unwrap_or_else(std::sync::PoisonError::into_inner);
1238 map.insert(
1239 root.clone(),
1240 CacheEntry {
1241 index: None,
1242 building: true,
1243 last_verified: None,
1244 },
1245 );
1246 }
1247 assert_eq!(
1248 build_guarded(&root, true, false),
1249 BuildOutcome::Deferred,
1250 "a contended root must defer the proactive pre-warm"
1251 );
1252 let map = cache()
1253 .lock()
1254 .unwrap_or_else(std::sync::PoisonError::into_inner);
1255 let entry = map.get(&root).expect("entry present");
1256 assert!(
1257 !entry.building,
1258 "deferred build must clear the in-flight flag so a later nudge retries"
1259 );
1260 assert!(
1261 entry.index.is_none(),
1262 "deferred build must not run a second walk / install an index"
1263 );
1264 }
1265
1266 #[test]
1267 fn corpus_signature_matches_a_freshly_built_index() {
1268 let dir = corpus();
1269 let root = dir.path().to_string_lossy().to_string();
1270 let idx = SearchIndex::build(&root, true, false).expect("index builds");
1271 let sig = corpus_signature(&root, true, false).expect("signature computes");
1272 assert_eq!(
1273 idx.signature, sig,
1274 "a freshly built index must match the live corpus signature, or it \
1275 would be treated as permanently stale and never served"
1276 );
1277 assert_eq!(sig, corpus_signature(&root, true, false).unwrap());
1279 }
1280
1281 #[test]
1282 fn corpus_signature_changes_on_edit_add_and_delete() {
1283 let dir = corpus();
1284 let root = dir.path().to_string_lossy().to_string();
1285 let base = corpus_signature(&root, true, false).unwrap();
1286
1287 std::fs::write(
1288 dir.path().join("a.rs"),
1289 "fn handler() {}\nlet x = 1;\nlet y = 2;\n",
1290 )
1291 .unwrap();
1292 let after_edit = corpus_signature(&root, true, false).unwrap();
1293 assert_ne!(
1294 base, after_edit,
1295 "an in-place edit must change the signature"
1296 );
1297
1298 std::fs::write(dir.path().join("d.rs"), "fn fresh() {}\n").unwrap();
1299 let after_add = corpus_signature(&root, true, false).unwrap();
1300 assert_ne!(
1301 after_edit, after_add,
1302 "adding a file must change the signature"
1303 );
1304
1305 std::fs::remove_file(dir.path().join("b.rs")).unwrap();
1306 let after_delete = corpus_signature(&root, true, false).unwrap();
1307 assert_ne!(
1308 after_add, after_delete,
1309 "deleting a file must change the signature"
1310 );
1311 }
1312
1313 #[test]
1314 fn get_fresh_serves_unchanged_then_refuses_after_edit() {
1315 let _env = crate::core::data_dir::test_env_lock();
1316 let data = tempfile::tempdir().unwrap();
1317 let _guard = DataDirGuard::set(data.path());
1318 crate::test_env::remove_var("LEAN_CTX_DISABLE_SEARCH_INDEX");
1319 crate::test_env::remove_var("LEAN_CTX_SEARCH_INDEX_COALESCE_MS");
1320
1321 let dir = corpus();
1322 let root = dir.path().to_string_lossy().to_string();
1323 assert!(warm_blocking(&root, true, false), "index warms");
1324
1325 assert!(
1327 get_fresh(&root, true, false).is_some(),
1328 "an unchanged corpus must serve the resident index"
1329 );
1330
1331 std::fs::write(
1334 dir.path().join("a.rs"),
1335 "fn handler() {}\nlet x = 1;\nlet z = 9;\n",
1336 )
1337 .unwrap();
1338 assert!(
1339 get_fresh(&root, true, false).is_none(),
1340 "an edited corpus must refuse the stale resident index (#624)"
1341 );
1342 }
1343}