1use std::collections::HashMap;
9use std::path::Path;
10
11use rayon::prelude::*;
12use serde::{Deserialize, Serialize};
13
14use crate::core::context_admission::stores::StoreAdmission;
15use crate::core::import_resolver;
16use crate::core::signatures;
17mod edges;
18pub(crate) use edges::*;
19#[allow(unreachable_pub)]
20#[allow(dead_code)]
21pub(crate) mod file_id;
22#[cfg(test)]
23mod tests;
24
25const INDEX_VERSION: u32 = 7;
28
29use crate::core::index_paths::normalize_absolute_path;
32pub use crate::core::index_paths::{graph_match_key, graph_relative_key, normalize_project_root};
33
34pub fn is_safe_scan_root_public(path: &str) -> bool {
35 is_safe_scan_root(path)
36}
37
38fn is_filesystem_root(path: &str) -> bool {
39 let p = Path::new(path);
40 p.parent().is_none() || (cfg!(windows) && p.parent() == Some(Path::new("")))
41}
42
43fn dir_has_project_marker(dir: &Path) -> bool {
49 crate::core::pathutil::has_project_marker(dir)
50}
51
52fn has_marker_in_ancestry(p: &Path, stop: &Path) -> bool {
59 let mut cur = Some(p);
60 while let Some(dir) = cur {
61 if dir == stop {
62 return false;
63 }
64 if dir_has_project_marker(dir) {
65 return true;
66 }
67 cur = dir.parent();
68 }
69 false
70}
71
72fn is_inside_project(p: &Path) -> bool {
76 let home = dirs::home_dir()
77 .map(|home| std::path::PathBuf::from(normalize_project_root(&home.to_string_lossy())));
78 let stop = home
79 .filter(|home| p.starts_with(home))
80 .or_else(|| p.ancestors().last().map(Path::to_path_buf))
81 .unwrap_or_default();
82 has_marker_in_ancestry(p, &stop)
83}
84
85fn is_safe_scan_root(path: &str) -> bool {
86 let normalized = normalize_project_root(path);
87 let p = Path::new(&normalized);
88
89 if !crate::core::pathutil::may_probe_path(p) {
94 return false;
95 }
96
97 if normalized == "/" || normalized == "\\" || is_filesystem_root(&normalized) {
98 tracing::warn!("[graph_index: refusing to scan filesystem root]");
99 return false;
100 }
101
102 if normalized == "." || normalized.is_empty() {
103 tracing::warn!("[graph_index: refusing to scan relative/empty root]");
104 return false;
105 }
106
107 for home in crate::core::pathutil::home_dirs() {
110 let home_norm = normalize_project_root(&home.to_string_lossy());
111 if normalized == home_norm {
112 use std::sync::Once;
113 static HOME_WARN: Once = Once::new();
114 HOME_WARN.call_once(|| {
115 tracing::warn!(
116 "[graph_index: skipping — cannot index home directory {normalized}.\n \
117 Run from inside a project, or set LEAN_CTX_PROJECT_ROOT=/path/to/project]"
118 );
119 });
120 return false;
121 }
122 if crate::core::pathutil::is_tcc_sensitive_home_dir(p) {
126 tracing::warn!(
127 "[graph_index: refusing to scan {normalized} — macOS TCC-protected home dir]"
128 );
129 return false;
130 }
131 let home_path = Path::new(&home_norm);
133 const BLOCKED_HOME_SUBDIRS: &[&str] = &[
134 "Desktop",
135 "Documents",
136 "Downloads",
137 "Pictures",
138 "Music",
139 "Videos",
140 "Movies",
141 "Library",
142 ".local",
143 ".cache",
144 ".config",
145 "snap",
146 "Applications",
147 "OneDrive",
152 "Dropbox",
153 "Google Drive",
154 ];
155 for blocked in BLOCKED_HOME_SUBDIRS {
156 let blocked_path = home_path.join(blocked);
157 let is_inside_blocked = p == blocked_path || p.starts_with(&blocked_path);
158 let has_marker = has_marker_in_ancestry(p, &blocked_path);
163 if is_inside_blocked
164 && !has_marker
165 && !crate::core::pathutil::has_multi_repo_children(p)
166 {
167 tracing::warn!(
168 "[graph_index: refusing to scan {normalized} — \
169 inside home/{blocked} without project markers]"
170 );
171 return false;
172 }
173 }
174
175 if p.parent() == Some(home_path)
178 && !dir_has_project_marker(p)
179 && !crate::core::pathutil::has_multi_repo_children(p)
180 {
181 tracing::warn!(
182 "[graph_index: refusing to scan {normalized} — \
183 direct child of home without project markers]"
184 );
185 return false;
186 }
187 }
188
189 let breadth_markers = [
190 ".git",
191 "Cargo.toml",
192 "package.json",
193 "go.mod",
194 "pyproject.toml",
195 "setup.py",
196 "Makefile",
197 "CMakeLists.txt",
198 "pnpm-workspace.yaml",
199 ".projectile",
200 "BUILD.bazel",
201 "go.work",
202 ];
203
204 if !breadth_markers.iter().any(|m| p.join(m).exists())
207 && !dir_has_dotnet_project(p)
208 && !is_inside_project(p)
209 {
210 if crate::core::pathutil::has_multi_repo_children(p) {
212 return true;
213 }
214
215 let child_count = std::fs::read_dir(p).map_or(0, |rd| {
216 rd.filter_map(Result::ok)
217 .filter(|e| e.path().is_dir())
218 .count()
219 });
220 if child_count > 50 {
221 tracing::warn!(
222 "[graph_index: {normalized} has no project markers and {child_count} subdirectories — \
223 skipping scan to avoid indexing broad directories]"
224 );
225 return false;
226 }
227 }
228
229 true
230}
231
232fn dir_has_dotnet_project(dir: &Path) -> bool {
236 std::fs::read_dir(dir).is_ok_and(|rd| {
237 rd.filter_map(Result::ok).any(|e| {
238 e.path()
239 .extension()
240 .and_then(|x| x.to_str())
241 .is_some_and(|x| {
242 matches!(
243 x.to_ascii_lowercase().as_str(),
244 "csproj" | "sln" | "fsproj" | "vbproj"
245 )
246 })
247 })
248 })
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ProjectIndex {
253 pub version: u32,
254 pub project_root: String,
255 pub last_scan: String,
256 pub files: HashMap<String, FileEntry>,
257 pub edges: Vec<IndexEdge>,
258 pub symbols: HashMap<String, SymbolEntry>,
259 #[serde(skip)]
263 pub(crate) interner: file_id::PathInterner,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267pub struct FileEntry {
268 pub path: String,
269 pub hash: String,
270 pub language: String,
271 pub line_count: usize,
272 pub token_count: usize,
273 pub exports: Vec<String>,
274 pub summary: String,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
278pub struct SymbolEntry {
279 pub file: String,
280 pub name: String,
281 pub kind: String,
282 pub start_line: usize,
283 pub end_line: usize,
284 pub is_exported: bool,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
288pub struct IndexEdge {
289 pub from: String,
290 pub to: String,
291 pub kind: String,
292 #[serde(default = "default_edge_weight")]
293 pub weight: f32,
294}
295
296fn default_edge_weight() -> f32 {
297 1.0
298}
299
300impl ProjectIndex {
301 #[cfg(any(test, feature = "test-support"))]
308 pub fn on_disk(mut self, also: &[&str]) -> (Self, tempfile::TempDir) {
309 let directory = tempfile::tempdir().expect("project directory");
310 let root = std::fs::canonicalize(directory.path()).expect("canonical project root");
311 let mut texts: HashMap<String, String> = self
312 .files
313 .keys()
314 .map(|path| (path.clone(), String::new()))
315 .collect();
316 for path in also {
317 texts.entry((*path).to_owned()).or_default();
318 }
319 for symbol in self.symbols.values() {
320 let text = texts.entry(symbol.file.clone()).or_default();
321 text.push_str(&symbol.name);
322 text.push('\n');
323 }
324 for (path, text) in texts {
325 let file = root.join(&path);
326 std::fs::create_dir_all(file.parent().expect("parent")).expect("directories");
327 std::fs::write(file, text).expect("project file");
328 }
329 self.project_root = Self::new(&root.to_string_lossy()).project_root;
330 (self, directory)
331 }
332
333 pub fn new(project_root: &str) -> Self {
334 Self {
335 version: INDEX_VERSION,
336 project_root: normalize_project_root(project_root),
337 last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
338 files: HashMap::new(),
339 edges: Vec::new(),
340 symbols: HashMap::new(),
341 interner: file_id::PathInterner::new(),
342 }
343 }
344
345 pub(crate) fn rebuild_interner(&mut self) {
349 let mut interner = file_id::PathInterner::with_capacity(self.files.len());
350 for key in self.files.keys() {
351 interner.intern(key);
352 }
353 self.interner = interner;
354 }
355
356 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
357 let normalized = normalize_project_root(project_root);
358 let hash = crate::core::project_hash::hash_project_root(&normalized);
359 crate::core::data_dir::lean_ctx_data_dir()
360 .ok()
361 .map(|d| d.join("graphs").join(hash))
362 }
363
364 pub fn load(project_root: &str) -> Option<Self> {
370 let graph = crate::core::property_graph::CodeGraph::open(project_root).ok()?;
371 if graph.file_catalog_count().unwrap_or(0) == 0 {
372 return None;
373 }
374 let provider = crate::core::graph_provider::GraphProvider::PropertyGraph(graph);
375 let mut index = provider.materialize_project_index(project_root);
376 index.rebuild_interner();
377 Some(index)
378 }
379
380 pub fn save(&self) -> Result<(), String> {
385 crate::core::property_graph::mirror_index(&self.project_root, self)
386 .map_err(|e| e.to_string())
387 }
388
389 pub fn purge_stale_indices() {
392 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
393 return;
394 };
395 let graphs_dir = data_dir.join("graphs");
396 let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
397 return;
398 };
399 let cfg = crate::core::config::Config::load();
400 let max_age_secs = cfg.archive_max_age_hours_effective() * 3600;
401
402 for entry in entries.filter_map(Result::ok) {
403 let path = entry.path();
404 if !path.is_dir() {
405 continue;
406 }
407 let meta = path.join("graph.meta.json");
411 let db = path.join("graph.db");
412 let index_file = if meta.exists() {
413 &meta
414 } else if db.exists() {
415 &db
416 } else {
417 continue;
418 };
419
420 let is_old = index_file
421 .metadata()
422 .and_then(|m| m.modified())
423 .is_ok_and(|mtime| {
424 mtime
425 .elapsed()
426 .is_ok_and(|age| age.as_secs() > max_age_secs)
427 });
428
429 if is_old {
430 tracing::info!("[graph_index: purging stale index at {}]", path.display());
431 let _ = std::fs::remove_dir_all(&path);
432 }
433 }
434 }
435
436 pub fn file_count(&self) -> usize {
437 self.files.len()
438 }
439
440 pub fn symbol_count(&self) -> usize {
441 self.symbols.len()
442 }
443
444 pub fn edge_count(&self) -> usize {
445 self.edges.len()
446 }
447
448 pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
449 self.symbols.get(key)
450 }
451
452 pub fn get_reverse_deps(&self, path: &str, depth: usize) -> Vec<String> {
453 let mut result = Vec::new();
454 let mut visited = std::collections::HashSet::new();
455 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
456
457 while let Some((current, d)) = queue.pop() {
458 if d > depth || visited.contains(¤t) {
459 continue;
460 }
461 visited.insert(current.clone());
462 if current != path {
463 result.push(current.clone());
464 }
465
466 for edge in &self.edges {
467 if edge.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
468 queue.push((edge.from.clone(), d + 1));
469 }
470 }
471 }
472 result
473 }
474
475 pub fn get_forward_deps(&self, path: &str, depth: usize) -> Vec<String> {
478 let mut result = Vec::new();
479 let mut visited = std::collections::HashSet::new();
480 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
481
482 while let Some((current, d)) = queue.pop() {
483 if d > depth || visited.contains(¤t) {
484 continue;
485 }
486 visited.insert(current.clone());
487 if current != path {
488 result.push(current.clone());
489 }
490
491 for edge in &self.edges {
492 if edge.from == current && edge.kind == "import" && !visited.contains(&edge.to) {
493 queue.push((edge.to.clone(), d + 1));
494 }
495 }
496 }
497 result
498 }
499
500 pub fn get_related(&self, path: &str, depth: usize) -> Vec<String> {
504 self.get_related_served(path, depth, &|_: &str| true)
505 }
506
507 pub(crate) fn get_related_served(
511 &self,
512 path: &str,
513 depth: usize,
514 served: &dyn Fn(&str) -> bool,
515 ) -> Vec<String> {
516 let mut result = Vec::new();
517 let mut visited = std::collections::HashSet::new();
518 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
519
520 while let Some((current, d)) = queue.pop() {
521 if d > depth || visited.contains(¤t) {
522 continue;
523 }
524 visited.insert(current.clone());
525 if current != path && !served(¤t) {
526 continue;
527 }
528 if current != path {
529 result.push(current.clone());
530 }
531
532 for edge in &self.edges {
533 if edge.from == current && !visited.contains(&edge.to) {
534 queue.push((edge.to.clone(), d + 1));
535 }
536 if edge.to == current && !visited.contains(&edge.from) {
537 queue.push((edge.from.clone(), d + 1));
538 }
539 }
540 }
541 result
542 }
543}
544
545pub fn load_or_build(project_root: &str) -> ProjectIndex {
549 if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
550 return ProjectIndex::load(project_root).unwrap_or_else(|| ProjectIndex::new(project_root));
551 }
552
553 let root_abs = if project_root.trim().is_empty() || project_root == "." {
556 std::env::current_dir().ok().map_or_else(
557 || ".".to_string(),
558 |p| normalize_project_root(&p.to_string_lossy()),
559 )
560 } else {
561 normalize_project_root(project_root)
562 };
563
564 if !is_safe_scan_root(&root_abs) {
565 return ProjectIndex::new(&root_abs);
566 }
567
568 if let Some(idx) = ProjectIndex::load(&root_abs)
570 && !idx.files.is_empty()
571 {
572 if index_looks_stale(&idx, &root_abs) {
573 tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
574 return scan(&root_abs);
575 }
576 return idx;
577 }
578
579 if let Ok(cwd) = std::env::current_dir() {
581 let cwd_str = normalize_project_root(&cwd.to_string_lossy());
582 if cwd_str != root_abs
583 && cwd_str.starts_with(&root_abs)
584 && let Some(idx) = ProjectIndex::load(&cwd_str)
585 && !idx.files.is_empty()
586 {
587 if index_looks_stale(&idx, &cwd_str) {
588 return scan(&cwd_str);
589 }
590 return idx;
591 }
592 }
593
594 scan(&root_abs)
595}
596
597fn index_looks_stale(index: &ProjectIndex, root_abs: &str) -> bool {
598 if index.files.is_empty() {
599 return true;
600 }
601
602 if index
605 .files
606 .keys()
607 .any(|rel| crate::core::io_boundary::is_secret_like(Path::new(rel)).is_some())
608 {
609 return true;
610 }
611
612 if let Ok(scan_time) =
614 chrono::NaiveDateTime::parse_from_str(&index.last_scan, "%Y-%m-%d %H:%M:%S")
615 {
616 let cfg = crate::core::config::Config::load();
617 let effective_hours = cfg.archive_max_age_hours_effective();
618 let max_age = chrono::Duration::hours(effective_hours as i64);
619 let now = chrono::Local::now().naive_local();
620 if now.signed_duration_since(scan_time) > max_age {
621 tracing::info!(
622 "[graph_index: index is older than {}h — marking stale]",
623 effective_hours
624 );
625 return true;
626 }
627 }
628
629 const CONTAMINATION_MARKERS: &[&str] = &[
632 "Desktop/",
633 "Documents/",
634 "Downloads/",
635 "Pictures/",
636 "Music/",
637 "Videos/",
638 "Movies/",
639 "Library/",
640 ".cache/",
641 "snap/",
642 ];
643 let contaminated = index.files.keys().take(200).any(|rel| {
644 CONTAMINATION_MARKERS
645 .iter()
646 .any(|m| rel.starts_with(m) || rel.contains(&format!("/{m}")))
647 });
648 if contaminated {
649 tracing::warn!(
650 "[graph_index: index contains files from user directories (Desktop/Documents/...) — \
651 marking stale to force clean rebuild]"
652 );
653 return true;
654 }
655
656 let root_path = Path::new(root_abs);
657 let sample_size = index.files.len().min(20);
659 for rel in index.files.keys().take(sample_size) {
660 let rel = rel.trim_start_matches(['/', '\\']);
661 if rel.is_empty() {
662 continue;
663 }
664 let abs = root_path.join(rel);
665 if !abs.exists() {
666 return true;
667 }
668 }
669
670 if source_content_changed_since_index(index, root_abs) {
675 tracing::info!("[graph_index: source content changed since last scan — marking stale]");
676 return true;
677 }
678
679 false
680}
681
682fn index_file_mtime(root_abs: &str) -> Option<std::time::SystemTime> {
688 let dir = ProjectIndex::index_dir(root_abs)?;
689 for name in ["graph.meta.json", "graph.db"] {
690 if let Ok(meta) = std::fs::metadata(dir.join(name))
691 && let Ok(modified) = meta.modified()
692 {
693 return Some(modified);
694 }
695 }
696 None
697}
698
699fn source_content_changed_since_index(index: &ProjectIndex, root_abs: &str) -> bool {
712 let Some(index_mtime) = index_file_mtime(root_abs) else {
713 return false;
715 };
716 let index_filter = crate::core::index_filter::IndexFileFilter::effective();
719 let walker = ignore::WalkBuilder::new(root_abs)
720 .hidden(crate::core::walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK)
722 .git_ignore(index_filter.respect_gitignore)
723 .git_global(index_filter.respect_gitignore)
724 .git_exclude(index_filter.respect_gitignore)
725 .require_git(false)
726 .max_depth(Some(20))
727 .filter_entry(crate::core::walk_filter::keep_entry)
728 .build();
729 const MAX_VISIT: usize = 50_000;
730 const MAX_CONFIRM_READS: usize = 4_000;
731 let mut visited = 0usize;
732 let mut confirm_reads = 0usize;
733 for entry in walker.filter_map(std::result::Result::ok) {
734 visited += 1;
735 if visited > MAX_VISIT {
736 break;
737 }
738 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
739 continue;
740 }
741 let path = entry.path();
742 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
743 if !is_indexable_ext(ext) {
744 continue;
745 }
746 let Ok(meta) = entry.metadata() else { continue };
748 let Ok(modified) = meta.modified() else {
749 continue;
750 };
751 if modified <= index_mtime {
752 continue;
753 }
754 let rel = make_relative(&path.to_string_lossy(), root_abs);
756 if index_filter.is_excluded(&rel.replace('\\', "/"))
757 || crate::core::io_boundary::is_secret_like(Path::new(&rel)).is_some()
758 {
759 continue;
760 }
761 let Some(file_entry) = index.files.get(&rel) else {
762 return true;
764 };
765 confirm_reads += 1;
766 if confirm_reads > MAX_CONFIRM_READS {
767 return true;
769 }
770 match crate::core::text_decode::read_text(path) {
771 Ok(content) if compute_hash(&content) == file_entry.hash => {}
773 _ => return true,
775 }
776 }
777 false
778}
779
780pub fn purge_index(project_root: &str) {
786 if let Some(dir) = ProjectIndex::index_dir(project_root) {
787 for name in [
788 "graph.db",
789 "graph.db-wal",
790 "graph.db-shm",
791 "graph.meta.json",
792 "index.json.zst",
793 "index.json",
794 "call_graph.json.zst",
795 ] {
796 let _ = std::fs::remove_file(dir.join(name));
797 }
798 }
799}
800
801pub fn scan(project_root: &str) -> ProjectIndex {
802 scan_inner(project_root).0
803}
804
805pub fn scan_with_content_cache(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
806 scan_inner(project_root)
807}
808
809fn scan_inner(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
810 if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
811 tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
812 return (ProjectIndex::new(project_root), HashMap::new());
813 }
814
815 let progress_root = project_root.to_string();
816 let project_root = normalize_project_root(project_root);
817
818 if !is_safe_scan_root(&project_root) {
819 tracing::debug!("[graph_index: scan aborted for unsafe root {project_root}]");
820 return (ProjectIndex::new(&project_root), HashMap::new());
821 }
822
823 let lock_name = format!(
824 "graph-idx-{}",
825 &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
826 );
827 let _lock = crate::core::startup_guard::try_acquire_lock(
828 &lock_name,
829 std::time::Duration::from_millis(800),
830 std::time::Duration::from_mins(3),
831 );
832 if _lock.is_none() {
833 tracing::info!(
834 "[graph_index: another process is scanning {project_root} — returning cached or empty]"
835 );
836 return (
837 ProjectIndex::load(&project_root).unwrap_or_else(|| ProjectIndex::new(&project_root)),
838 HashMap::new(),
839 );
840 }
841
842 let existing = ProjectIndex::load(&project_root);
843 let mut index = ProjectIndex::new(&project_root);
844
845 let previous_symbols = existing
849 .as_ref()
850 .map(previous_symbols_by_file)
851 .unwrap_or_default();
852
853 let cfg = crate::core::config::Config::load();
854 let index_filter = crate::core::index_filter::IndexFileFilter::resolve(&cfg);
856
857 let walker = ignore::WalkBuilder::new(&project_root)
858 .hidden(crate::core::walk_filter::SKIP_HIDDEN_IN_CONTENT_WALK)
860 .git_ignore(index_filter.respect_gitignore)
861 .git_global(index_filter.respect_gitignore)
862 .git_exclude(index_filter.respect_gitignore)
863 .require_git(false)
864 .max_depth(Some(20))
865 .filter_entry(crate::core::walk_filter::keep_entry)
866 .build();
867
868 let extra_ignores: Vec<glob::Pattern> = cfg
869 .extra_ignore_patterns
870 .iter()
871 .filter_map(|p| glob::Pattern::new(p).ok())
872 .collect();
873
874 let mut scanned = 0usize;
875 let mut reused = 0usize;
876 let mut entries_visited = 0usize;
877 let mut content_cache: HashMap<String, String> = HashMap::new();
878 let mut content_cache_bytes: usize = 0;
879 const CONTENT_CACHE_MAX_BYTES: usize = 256 * 1024 * 1024; let max_files = if cfg.graph_index_max_files == 0 {
881 usize::MAX } else {
883 cfg.graph_index_max_files as usize
884 };
885 const MAX_ENTRIES_VISITED: usize = 500_000;
886 const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024; const SCAN_BATCH_FILES: usize = 500;
889 const SCAN_MIN_BATCH_FILES: usize = 1;
890 const SCAN_EST_TRANSIENT_PER_FILE: u64 = 192 * 1024;
894 let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(5);
895
896 let mut targets: Vec<(String, String, String)> = Vec::new();
904 for entry in walker.filter_map(std::result::Result::ok) {
905 entries_visited += 1;
906 if entries_visited > MAX_ENTRIES_VISITED {
907 tracing::warn!(
908 "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
909 runaway traversal. Indexed {} files so far.]",
910 targets.len()
911 );
912 break;
913 }
914 if entries_visited.is_multiple_of(5000) {
915 if std::time::Instant::now() > scan_deadline {
916 tracing::warn!(
917 "[graph_index: scan timeout (120s) after {entries_visited} entries — \
918 saving partial index with {} files]",
919 targets.len()
920 );
921 break;
922 }
923 if crate::core::memory_guard::abort_requested() {
924 tracing::warn!(
925 "[graph_index: memory pressure abort after {entries_visited} entries — \
926 saving partial index with {} files]",
927 targets.len()
928 );
929 break;
930 }
931 if crate::core::memory_guard::is_under_pressure() {
932 tracing::warn!(
933 "[graph_index: memory pressure detected at {entries_visited} entries — \
934 stopping scan with {} files]",
935 targets.len()
936 );
937 break;
938 }
939 if let Some(ref g) = _lock {
940 g.touch();
941 }
942 }
943
944 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
945 continue;
946 }
947
948 if entry.path_is_symlink() {
949 continue;
950 }
951 let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
952
953 if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
954 continue;
955 }
956
957 if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
958 if meta.file_type().is_symlink() || !meta.is_file() {
959 continue;
960 }
961 if meta.len() > MAX_FILE_SIZE_BYTES {
962 tracing::debug!(
963 "[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
964 meta.len() as f64 / 1_048_576.0,
965 MAX_FILE_SIZE_BYTES / (1024 * 1024),
966 );
967 continue;
968 }
969 }
970
971 let ext = Path::new(&file_path)
972 .extension()
973 .and_then(|e| e.to_str())
974 .unwrap_or("");
975
976 if !is_indexable_ext(ext) {
977 continue;
978 }
979
980 let rel = make_relative(&file_path, &project_root);
981 if crate::core::io_boundary::is_secret_like(Path::new(&rel)).is_some() {
985 continue;
986 }
987 if extra_ignores.iter().any(|p| p.matches(&rel)) {
988 continue;
989 }
990 if index_filter.is_excluded(&rel.replace('\\', "/")) {
991 continue;
992 }
993
994 if max_files != usize::MAX && targets.len() >= max_files {
995 tracing::info!(
996 "[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
997 max_files
998 );
999 break;
1000 }
1001
1002 let ext = ext.to_string();
1004 targets.push((file_path, rel, ext));
1005 }
1006
1007 let target_rels: Vec<String> = targets.iter().map(|(_, r, _)| r.clone()).collect();
1011 let admission = crate::core::index_admission::admit_files(
1012 crate::core::index_admission::BuildKind::GraphScan,
1013 std::path::Path::new(&project_root),
1014 &target_rels,
1015 );
1016 let parallel = admission.parallel_ok && !crate::core::memory_guard::is_under_pressure();
1017 let store_admission = StoreAdmission::current();
1019 let progress = crate::core::index_progress::ProgressGuard::new(
1022 progress_root,
1023 crate::core::index_progress::IndexComponent::Graph,
1024 );
1025 let files_total = targets.len() as u64;
1026 progress.report(0, files_total);
1027 let mut files_done = 0;
1028 while files_done < targets.len() {
1029 if crate::core::memory_guard::abort_requested() {
1030 tracing::warn!(
1031 "[graph_index: aborting scan after {files_done} files due to critical memory pressure]"
1032 );
1033 break;
1034 }
1035 if crate::core::memory_guard::is_under_pressure() {
1036 tracing::warn!(
1037 "[graph_index: stopping scan after {files_done} files due to memory pressure]"
1038 );
1039 break;
1040 }
1041 let batch_size = crate::core::memory_guard::adaptive_batch_size(
1042 SCAN_MIN_BATCH_FILES,
1043 SCAN_BATCH_FILES,
1044 SCAN_EST_TRANSIENT_PER_FILE,
1045 );
1046 let batch_end = (files_done + batch_size).min(targets.len());
1047 let results = process_scan_targets(
1048 &targets[files_done..batch_end],
1049 &previous_symbols,
1050 existing.as_ref(),
1051 parallel,
1052 &store_admission,
1053 );
1054 for r in results {
1055 if r.reused {
1056 reused += 1;
1057 } else {
1058 scanned += 1;
1059 }
1060 index.files.insert(r.rel.clone(), r.file_entry);
1061 for (key, sym) in r.symbols {
1062 index.symbols.insert(key, sym);
1063 }
1064 if content_cache_bytes < CONTENT_CACHE_MAX_BYTES {
1065 content_cache_bytes += r.content.len();
1066 content_cache.insert(r.rel, r.content);
1067 }
1068 }
1069 files_done = batch_end;
1070 progress.report(files_done as u64, files_total);
1071 crate::core::memory_guard::jemalloc_purge();
1072 }
1073
1074 index.rebuild_interner();
1075 build_edges_cached(&mut index, &content_cache, scan_deadline);
1078
1079 if let Err(e) = index.save() {
1080 tracing::warn!("could not save graph index: {e}");
1081 }
1082
1083 tracing::debug!(
1084 "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
1085 index.file_count(),
1086 scanned,
1087 reused,
1088 index.symbol_count(),
1089 index.edge_count()
1090 );
1091
1092 (index, content_cache)
1093}
1094
1095type PreviousSymbols<'a> = HashMap<&'a str, Vec<(&'a str, &'a SymbolEntry)>>;
1099
1100fn previous_symbols_by_file(index: &ProjectIndex) -> PreviousSymbols<'_> {
1101 let mut by_file: PreviousSymbols<'_> = HashMap::new();
1102 for (key, symbol) in &index.symbols {
1103 by_file
1104 .entry(symbol.file.as_str())
1105 .or_default()
1106 .push((key.as_str(), symbol));
1107 }
1108 by_file
1109}
1110
1111#[derive(Debug, PartialEq)]
1114struct ScanFileResult {
1115 rel: String,
1116 file_entry: FileEntry,
1117 symbols: Vec<(String, SymbolEntry)>,
1119 content: String,
1120 reused: bool,
1121}
1122
1123fn process_scan_file(
1127 file_path: &str,
1128 rel: &str,
1129 ext: &str,
1130 previous_symbols: &PreviousSymbols<'_>,
1131 existing: Option<&ProjectIndex>,
1132 admission: &StoreAdmission,
1133) -> Option<ScanFileResult> {
1134 if crate::core::memory_guard::abort_requested() {
1135 return None;
1136 }
1137 let content = crate::core::text_decode::read_text(file_path).ok()?;
1138 let hash = compute_hash(&content);
1139
1140 if let Some(old_entry) = existing.and_then(|p| p.files.get(rel))
1144 && old_entry.hash == hash
1145 {
1146 let symbols = previous_symbols
1147 .get(rel)
1148 .into_iter()
1149 .flatten()
1150 .map(|(key, symbol)| ((*key).to_string(), (*symbol).clone()))
1151 .collect();
1152 return Some(ScanFileResult {
1153 rel: rel.to_string(),
1154 file_entry: old_entry.clone(),
1155 symbols,
1156 content,
1157 reused: true,
1158 });
1159 }
1160
1161 if crate::core::memory_guard::abort_requested() {
1162 return None;
1163 }
1164
1165 let sigs = signatures::extract_signatures(&content, ext);
1166 let line_count = content.lines().count();
1167 let token_count = crate::core::tokens::count_tokens(&content);
1168 let summary = admission
1171 .admit(&extract_summary(&content), std::path::Path::new(file_path))
1172 .unwrap_or_default();
1173
1174 let exports: Vec<String> = sigs
1175 .iter()
1176 .filter(|s| s.is_exported)
1177 .map(|s| s.name.clone())
1178 .collect();
1179
1180 let file_entry = FileEntry {
1181 path: rel.to_string(),
1182 hash,
1183 language: ext.to_string(),
1184 line_count,
1185 token_count,
1186 exports,
1187 summary,
1188 };
1189
1190 let symbols: Vec<(String, SymbolEntry)> = sigs
1191 .iter()
1192 .map(|sig| {
1193 let (start, end) = sig
1194 .start_line
1195 .zip(sig.end_line)
1196 .unwrap_or_else(|| find_symbol_range(&content, sig));
1197 let key = format!("{rel}::{}", sig.name);
1198 (
1199 key,
1200 SymbolEntry {
1201 file: rel.to_string(),
1202 name: sig.name.clone(),
1203 kind: sig.kind.to_string(),
1204 start_line: start,
1205 end_line: end,
1206 is_exported: sig.is_exported,
1207 },
1208 )
1209 })
1210 .collect();
1211
1212 Some(ScanFileResult {
1213 rel: rel.to_string(),
1214 file_entry,
1215 symbols,
1216 content,
1217 reused: false,
1218 })
1219}
1220
1221fn process_scan_targets(
1226 targets: &[(String, String, String)],
1227 previous_symbols: &PreviousSymbols<'_>,
1228 existing: Option<&ProjectIndex>,
1229 parallel: bool,
1230 admission: &StoreAdmission,
1231) -> Vec<ScanFileResult> {
1232 if parallel {
1233 targets
1234 .par_iter()
1235 .filter_map(|(file_path, rel, ext)| {
1236 process_scan_file(file_path, rel, ext, previous_symbols, existing, admission)
1237 })
1238 .collect()
1239 } else {
1240 targets
1241 .iter()
1242 .filter_map(|(file_path, rel, ext)| {
1243 process_scan_file(file_path, rel, ext, previous_symbols, existing, admission)
1244 })
1245 .collect()
1246 }
1247}
1248
1249pub fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
1250 let lines: Vec<&str> = content.lines().collect();
1251 let mut start = 0;
1252
1253 for (i, line) in lines.iter().enumerate() {
1254 if line.contains(&sig.name) {
1255 let trimmed = line.trim();
1256 let is_def = trimmed.starts_with("fn ")
1257 || trimmed.starts_with("pub fn ")
1258 || trimmed.starts_with("pub(crate) fn ")
1259 || trimmed.starts_with("async fn ")
1260 || trimmed.starts_with("pub async fn ")
1261 || trimmed.starts_with("struct ")
1262 || trimmed.starts_with("pub struct ")
1263 || trimmed.starts_with("enum ")
1264 || trimmed.starts_with("pub enum ")
1265 || trimmed.starts_with("trait ")
1266 || trimmed.starts_with("pub trait ")
1267 || trimmed.starts_with("impl ")
1268 || trimmed.starts_with("class ")
1269 || trimmed.starts_with("export class ")
1270 || trimmed.starts_with("export function ")
1271 || trimmed.starts_with("export async function ")
1272 || trimmed.starts_with("function ")
1273 || trimmed.starts_with("async function ")
1274 || trimmed.starts_with("def ")
1275 || trimmed.starts_with("async def ")
1276 || trimmed.starts_with("func ")
1277 || trimmed.starts_with("interface ")
1278 || trimmed.starts_with("export interface ")
1279 || trimmed.starts_with("type ")
1280 || trimmed.starts_with("export type ")
1281 || trimmed.starts_with("const ")
1282 || trimmed.starts_with("export const ")
1283 || trimmed.starts_with("fun ")
1284 || trimmed.starts_with("private fun ")
1285 || trimmed.starts_with("public fun ")
1286 || trimmed.starts_with("internal fun ")
1287 || trimmed.starts_with("class ")
1288 || trimmed.starts_with("data class ")
1289 || trimmed.starts_with("sealed class ")
1290 || trimmed.starts_with("sealed interface ")
1291 || trimmed.starts_with("enum class ")
1292 || trimmed.starts_with("object ")
1293 || trimmed.starts_with("private object ")
1294 || trimmed.starts_with("interface ")
1295 || trimmed.starts_with("typealias ")
1296 || trimmed.starts_with("private typealias ");
1297 if is_def {
1298 start = i + 1;
1299 break;
1300 }
1301 }
1302 }
1303
1304 if start == 0 {
1305 return (1, lines.len().min(20));
1306 }
1307
1308 let base_indent = lines
1309 .get(start - 1)
1310 .map_or(0, |l| l.len() - l.trim_start().len());
1311
1312 let mut end = start;
1313 let mut brace_depth: i32 = 0;
1314 let mut found_open = false;
1315
1316 for (i, line) in lines.iter().enumerate().skip(start - 1) {
1317 for ch in line.chars() {
1318 if ch == '{' {
1319 brace_depth += 1;
1320 found_open = true;
1321 } else if ch == '}' {
1322 brace_depth -= 1;
1323 }
1324 }
1325
1326 end = i + 1;
1327
1328 if found_open && brace_depth <= 0 {
1329 break;
1330 }
1331
1332 if !found_open && i > start {
1333 let indent = line.len() - line.trim_start().len();
1334 if indent <= base_indent && !line.trim().is_empty() && i > start {
1335 end = i;
1336 break;
1337 }
1338 }
1339
1340 if end - start > 200 {
1341 break;
1342 }
1343 }
1344
1345 (start, end)
1346}
1347
1348fn extract_summary(content: &str) -> String {
1349 for line in content.lines().take(20) {
1350 let trimmed = line.trim();
1351 if trimmed.is_empty()
1352 || trimmed.starts_with("//")
1353 || trimmed.starts_with('#')
1354 || trimmed.starts_with("/*")
1355 || trimmed.starts_with('*')
1356 || trimmed.starts_with("use ")
1357 || trimmed.starts_with("import ")
1358 || trimmed.starts_with("from ")
1359 || trimmed.starts_with("require(")
1360 || trimmed.starts_with("package ")
1361 {
1362 continue;
1363 }
1364 return trimmed.chars().take(120).collect();
1365 }
1366 String::new()
1367}
1368
1369fn compute_hash(content: &str) -> String {
1370 use std::collections::hash_map::DefaultHasher;
1371 use std::hash::{Hash, Hasher};
1372
1373 let mut hasher = DefaultHasher::new();
1374 content.hash(&mut hasher);
1375 format!("{:016x}", hasher.finish())
1376}
1377
1378#[cfg(test)]
1379fn short_hash(input: &str) -> String {
1380 use std::collections::hash_map::DefaultHasher;
1381 use std::hash::{Hash, Hasher};
1382
1383 let mut hasher = DefaultHasher::new();
1384 input.hash(&mut hasher);
1385 format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1386}
1387
1388fn make_relative(path: &str, root: &str) -> String {
1389 graph_relative_key(path, root)
1390}
1391
1392fn is_indexable_ext(ext: &str) -> bool {
1393 crate::core::language_capabilities::is_indexable_ext(ext)
1394}
1395
1396#[cfg(test)]
1397fn kotlin_package_name(content: &str) -> Option<String> {
1398 content.lines().map(str::trim).find_map(|line| {
1399 line.strip_prefix("package ")
1400 .map(|rest| rest.trim().trim_end_matches(';').to_string())
1401 })
1402}