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(
1172 &extract_summary(&content),
1173 std::path::Path::new(file_path),
1174 None,
1175 )
1176 .unwrap_or_default();
1177
1178 let exports: Vec<String> = sigs
1179 .iter()
1180 .filter(|s| s.is_exported)
1181 .map(|s| s.name.clone())
1182 .collect();
1183
1184 let file_entry = FileEntry {
1185 path: rel.to_string(),
1186 hash,
1187 language: ext.to_string(),
1188 line_count,
1189 token_count,
1190 exports,
1191 summary,
1192 };
1193
1194 let symbols: Vec<(String, SymbolEntry)> = sigs
1195 .iter()
1196 .map(|sig| {
1197 let (start, end) = sig
1198 .start_line
1199 .zip(sig.end_line)
1200 .unwrap_or_else(|| find_symbol_range(&content, sig));
1201 let key = format!("{rel}::{}", sig.name);
1202 (
1203 key,
1204 SymbolEntry {
1205 file: rel.to_string(),
1206 name: sig.name.clone(),
1207 kind: sig.kind.to_string(),
1208 start_line: start,
1209 end_line: end,
1210 is_exported: sig.is_exported,
1211 },
1212 )
1213 })
1214 .collect();
1215
1216 Some(ScanFileResult {
1217 rel: rel.to_string(),
1218 file_entry,
1219 symbols,
1220 content,
1221 reused: false,
1222 })
1223}
1224
1225fn process_scan_targets(
1230 targets: &[(String, String, String)],
1231 previous_symbols: &PreviousSymbols<'_>,
1232 existing: Option<&ProjectIndex>,
1233 parallel: bool,
1234 admission: &StoreAdmission,
1235) -> Vec<ScanFileResult> {
1236 if parallel {
1237 let carried = crate::core::context_admission::capture::carry();
1239 targets
1240 .par_iter()
1241 .filter_map(|(file_path, rel, ext)| {
1242 carried.enter(|| {
1243 process_scan_file(file_path, rel, ext, previous_symbols, existing, admission)
1244 })
1245 })
1246 .collect()
1247 } else {
1248 targets
1249 .iter()
1250 .filter_map(|(file_path, rel, ext)| {
1251 process_scan_file(file_path, rel, ext, previous_symbols, existing, admission)
1252 })
1253 .collect()
1254 }
1255}
1256
1257pub fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
1258 let lines: Vec<&str> = content.lines().collect();
1259 let mut start = 0;
1260
1261 for (i, line) in lines.iter().enumerate() {
1262 if line.contains(&sig.name) {
1263 let trimmed = line.trim();
1264 let is_def = trimmed.starts_with("fn ")
1265 || trimmed.starts_with("pub fn ")
1266 || trimmed.starts_with("pub(crate) fn ")
1267 || trimmed.starts_with("async fn ")
1268 || trimmed.starts_with("pub async fn ")
1269 || trimmed.starts_with("struct ")
1270 || trimmed.starts_with("pub struct ")
1271 || trimmed.starts_with("enum ")
1272 || trimmed.starts_with("pub enum ")
1273 || trimmed.starts_with("trait ")
1274 || trimmed.starts_with("pub trait ")
1275 || trimmed.starts_with("impl ")
1276 || trimmed.starts_with("class ")
1277 || trimmed.starts_with("export class ")
1278 || trimmed.starts_with("export function ")
1279 || trimmed.starts_with("export async function ")
1280 || trimmed.starts_with("function ")
1281 || trimmed.starts_with("async function ")
1282 || trimmed.starts_with("def ")
1283 || trimmed.starts_with("async def ")
1284 || trimmed.starts_with("func ")
1285 || trimmed.starts_with("interface ")
1286 || trimmed.starts_with("export interface ")
1287 || trimmed.starts_with("type ")
1288 || trimmed.starts_with("export type ")
1289 || trimmed.starts_with("const ")
1290 || trimmed.starts_with("export const ")
1291 || trimmed.starts_with("fun ")
1292 || trimmed.starts_with("private fun ")
1293 || trimmed.starts_with("public fun ")
1294 || trimmed.starts_with("internal fun ")
1295 || trimmed.starts_with("class ")
1296 || trimmed.starts_with("data class ")
1297 || trimmed.starts_with("sealed class ")
1298 || trimmed.starts_with("sealed interface ")
1299 || trimmed.starts_with("enum class ")
1300 || trimmed.starts_with("object ")
1301 || trimmed.starts_with("private object ")
1302 || trimmed.starts_with("interface ")
1303 || trimmed.starts_with("typealias ")
1304 || trimmed.starts_with("private typealias ");
1305 if is_def {
1306 start = i + 1;
1307 break;
1308 }
1309 }
1310 }
1311
1312 if start == 0 {
1313 return (1, lines.len().min(20));
1314 }
1315
1316 let base_indent = lines
1317 .get(start - 1)
1318 .map_or(0, |l| l.len() - l.trim_start().len());
1319
1320 let mut end = start;
1321 let mut brace_depth: i32 = 0;
1322 let mut found_open = false;
1323
1324 for (i, line) in lines.iter().enumerate().skip(start - 1) {
1325 for ch in line.chars() {
1326 if ch == '{' {
1327 brace_depth += 1;
1328 found_open = true;
1329 } else if ch == '}' {
1330 brace_depth -= 1;
1331 }
1332 }
1333
1334 end = i + 1;
1335
1336 if found_open && brace_depth <= 0 {
1337 break;
1338 }
1339
1340 if !found_open && i > start {
1341 let indent = line.len() - line.trim_start().len();
1342 if indent <= base_indent && !line.trim().is_empty() && i > start {
1343 end = i;
1344 break;
1345 }
1346 }
1347
1348 if end - start > 200 {
1349 break;
1350 }
1351 }
1352
1353 (start, end)
1354}
1355
1356fn extract_summary(content: &str) -> String {
1357 for line in content.lines().take(20) {
1358 let trimmed = line.trim();
1359 if trimmed.is_empty()
1360 || trimmed.starts_with("//")
1361 || trimmed.starts_with('#')
1362 || trimmed.starts_with("/*")
1363 || trimmed.starts_with('*')
1364 || trimmed.starts_with("use ")
1365 || trimmed.starts_with("import ")
1366 || trimmed.starts_with("from ")
1367 || trimmed.starts_with("require(")
1368 || trimmed.starts_with("package ")
1369 {
1370 continue;
1371 }
1372 return trimmed.chars().take(120).collect();
1373 }
1374 String::new()
1375}
1376
1377fn compute_hash(content: &str) -> String {
1378 use std::collections::hash_map::DefaultHasher;
1379 use std::hash::{Hash, Hasher};
1380
1381 let mut hasher = DefaultHasher::new();
1382 content.hash(&mut hasher);
1383 format!("{:016x}", hasher.finish())
1384}
1385
1386#[cfg(test)]
1387fn short_hash(input: &str) -> String {
1388 use std::collections::hash_map::DefaultHasher;
1389 use std::hash::{Hash, Hasher};
1390
1391 let mut hasher = DefaultHasher::new();
1392 input.hash(&mut hasher);
1393 format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1394}
1395
1396fn make_relative(path: &str, root: &str) -> String {
1397 graph_relative_key(path, root)
1398}
1399
1400fn is_indexable_ext(ext: &str) -> bool {
1401 crate::core::language_capabilities::is_indexable_ext(ext)
1402}
1403
1404#[cfg(test)]
1405fn kotlin_package_name(content: &str) -> Option<String> {
1406 content.lines().map(str::trim).find_map(|line| {
1407 line.strip_prefix("package ")
1408 .map(|rest| rest.trim().trim_end_matches(';').to_string())
1409 })
1410}