1use std::collections::HashMap;
9use std::path::Path;
10
11use rayon::prelude::*;
12use serde::{Deserialize, Serialize};
13
14use crate::core::import_resolver;
15use crate::core::signatures;
16mod edges;
17pub(crate) use edges::*;
18#[allow(unreachable_pub)]
19pub(crate) mod file_id;
20#[cfg(test)]
21mod tests;
22
23const INDEX_VERSION: u32 = 6;
24
25use crate::core::index_paths::normalize_absolute_path;
28pub use crate::core::index_paths::{graph_match_key, graph_relative_key, normalize_project_root};
29
30pub fn is_safe_scan_root_public(path: &str) -> bool {
31 is_safe_scan_root(path)
32}
33
34fn is_filesystem_root(path: &str) -> bool {
35 let p = Path::new(path);
36 p.parent().is_none() || (cfg!(windows) && p.parent() == Some(Path::new("")))
37}
38
39fn dir_has_project_marker(dir: &Path) -> bool {
45 crate::core::pathutil::has_project_marker(dir)
46}
47
48fn has_marker_in_ancestry(p: &Path, stop: &Path) -> bool {
55 let mut cur = Some(p);
56 while let Some(dir) = cur {
57 if dir == stop {
58 return false;
59 }
60 if dir_has_project_marker(dir) {
61 return true;
62 }
63 cur = dir.parent();
64 }
65 false
66}
67
68fn is_safe_scan_root(path: &str) -> bool {
69 let normalized = normalize_project_root(path);
70 let p = Path::new(&normalized);
71
72 if !crate::core::pathutil::may_probe_path(p) {
77 return false;
78 }
79
80 if normalized == "/" || normalized == "\\" || is_filesystem_root(&normalized) {
81 tracing::warn!("[graph_index: refusing to scan filesystem root]");
82 return false;
83 }
84
85 if normalized == "." || normalized.is_empty() {
86 tracing::warn!("[graph_index: refusing to scan relative/empty root]");
87 return false;
88 }
89
90 if let Some(home) = dirs::home_dir() {
91 let home_norm = normalize_project_root(&home.to_string_lossy());
92 if normalized == home_norm {
93 use std::sync::Once;
94 static HOME_WARN: Once = Once::new();
95 HOME_WARN.call_once(|| {
96 tracing::warn!(
97 "[graph_index: skipping — cannot index home directory {normalized}.\n \
98 Run from inside a project, or set LEAN_CTX_PROJECT_ROOT=/path/to/project]"
99 );
100 });
101 return false;
102 }
103 if crate::core::pathutil::is_tcc_sensitive_home_dir(p) {
107 tracing::warn!(
108 "[graph_index: refusing to scan {normalized} — macOS TCC-protected home dir]"
109 );
110 return false;
111 }
112 let home_path = Path::new(&home_norm);
114 const BLOCKED_HOME_SUBDIRS: &[&str] = &[
115 "Desktop",
116 "Documents",
117 "Downloads",
118 "Pictures",
119 "Music",
120 "Videos",
121 "Movies",
122 "Library",
123 ".local",
124 ".cache",
125 ".config",
126 "snap",
127 "Applications",
128 "OneDrive",
133 "Dropbox",
134 "Google Drive",
135 ];
136 for blocked in BLOCKED_HOME_SUBDIRS {
137 let blocked_path = home_path.join(blocked);
138 let is_inside_blocked = p == blocked_path || p.starts_with(&blocked_path);
139 let has_marker = has_marker_in_ancestry(p, &blocked_path);
144 if is_inside_blocked
145 && !has_marker
146 && !crate::core::pathutil::has_multi_repo_children(p)
147 {
148 tracing::warn!(
149 "[graph_index: refusing to scan {normalized} — \
150 inside home/{blocked} without project markers]"
151 );
152 return false;
153 }
154 }
155
156 if p.parent() == Some(home_path)
159 && !dir_has_project_marker(p)
160 && !crate::core::pathutil::has_multi_repo_children(p)
161 {
162 tracing::warn!(
163 "[graph_index: refusing to scan {normalized} — \
164 direct child of home without project markers]"
165 );
166 return false;
167 }
168 }
169
170 let breadth_markers = [
171 ".git",
172 "Cargo.toml",
173 "package.json",
174 "go.mod",
175 "pyproject.toml",
176 "setup.py",
177 "Makefile",
178 "CMakeLists.txt",
179 "pnpm-workspace.yaml",
180 ".projectile",
181 "BUILD.bazel",
182 "go.work",
183 ];
184
185 if !breadth_markers.iter().any(|m| p.join(m).exists()) && !dir_has_dotnet_project(p) {
186 if crate::core::pathutil::has_multi_repo_children(p) {
188 return true;
189 }
190
191 let child_count = std::fs::read_dir(p).map_or(0, |rd| {
192 rd.filter_map(Result::ok)
193 .filter(|e| e.path().is_dir())
194 .count()
195 });
196 if child_count > 50 {
197 tracing::warn!(
198 "[graph_index: {normalized} has no project markers and {child_count} subdirectories — \
199 skipping scan to avoid indexing broad directories]"
200 );
201 return false;
202 }
203 }
204
205 true
206}
207
208fn dir_has_dotnet_project(dir: &Path) -> bool {
212 std::fs::read_dir(dir).is_ok_and(|rd| {
213 rd.filter_map(Result::ok).any(|e| {
214 e.path()
215 .extension()
216 .and_then(|x| x.to_str())
217 .is_some_and(|x| {
218 matches!(
219 x.to_ascii_lowercase().as_str(),
220 "csproj" | "sln" | "fsproj" | "vbproj"
221 )
222 })
223 })
224 })
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct ProjectIndex {
229 pub version: u32,
230 pub project_root: String,
231 pub last_scan: String,
232 pub files: HashMap<String, FileEntry>,
233 pub edges: Vec<IndexEdge>,
234 pub symbols: HashMap<String, SymbolEntry>,
235 #[serde(skip)]
239 pub(crate) interner: file_id::PathInterner,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
243pub struct FileEntry {
244 pub path: String,
245 pub hash: String,
246 pub language: String,
247 pub line_count: usize,
248 pub token_count: usize,
249 pub exports: Vec<String>,
250 pub summary: String,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
254pub struct SymbolEntry {
255 pub file: String,
256 pub name: String,
257 pub kind: String,
258 pub start_line: usize,
259 pub end_line: usize,
260 pub is_exported: bool,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
264pub struct IndexEdge {
265 pub from: String,
266 pub to: String,
267 pub kind: String,
268 #[serde(default = "default_edge_weight")]
269 pub weight: f32,
270}
271
272fn default_edge_weight() -> f32 {
273 1.0
274}
275
276impl ProjectIndex {
277 pub fn new(project_root: &str) -> Self {
278 Self {
279 version: INDEX_VERSION,
280 project_root: normalize_project_root(project_root),
281 last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
282 files: HashMap::new(),
283 edges: Vec::new(),
284 symbols: HashMap::new(),
285 interner: file_id::PathInterner::new(),
286 }
287 }
288
289 pub(crate) fn rebuild_interner(&mut self) {
293 let mut interner = file_id::PathInterner::with_capacity(self.files.len());
294 for key in self.files.keys() {
295 interner.intern(key);
296 }
297 self.interner = interner;
298 }
299
300 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
301 let normalized = normalize_project_root(project_root);
302 let hash = crate::core::project_hash::hash_project_root(&normalized);
303 crate::core::data_dir::lean_ctx_data_dir()
304 .ok()
305 .map(|d| d.join("graphs").join(hash))
306 }
307
308 pub fn load(project_root: &str) -> Option<Self> {
314 let graph = crate::core::property_graph::CodeGraph::open(project_root).ok()?;
315 if graph.file_catalog_count().unwrap_or(0) == 0 {
316 return None;
317 }
318 let provider = crate::core::graph_provider::GraphProvider::PropertyGraph(graph);
319 let mut index = provider.materialize_project_index(project_root);
320 index.rebuild_interner();
321 Some(index)
322 }
323
324 pub fn save(&self) -> Result<(), String> {
329 crate::core::property_graph::mirror_index(&self.project_root, self)
330 .map_err(|e| e.to_string())
331 }
332
333 pub fn purge_stale_indices() {
336 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
337 return;
338 };
339 let graphs_dir = data_dir.join("graphs");
340 let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
341 return;
342 };
343 let cfg = crate::core::config::Config::load();
344 let max_age_secs = cfg.archive_max_age_hours_effective() * 3600;
345
346 for entry in entries.filter_map(Result::ok) {
347 let path = entry.path();
348 if !path.is_dir() {
349 continue;
350 }
351 let meta = path.join("graph.meta.json");
355 let db = path.join("graph.db");
356 let index_file = if meta.exists() {
357 &meta
358 } else if db.exists() {
359 &db
360 } else {
361 continue;
362 };
363
364 let is_old = index_file
365 .metadata()
366 .and_then(|m| m.modified())
367 .is_ok_and(|mtime| {
368 mtime
369 .elapsed()
370 .is_ok_and(|age| age.as_secs() > max_age_secs)
371 });
372
373 if is_old {
374 tracing::info!("[graph_index: purging stale index at {}]", path.display());
375 let _ = std::fs::remove_dir_all(&path);
376 }
377 }
378 }
379
380 pub fn file_count(&self) -> usize {
381 self.files.len()
382 }
383
384 pub fn symbol_count(&self) -> usize {
385 self.symbols.len()
386 }
387
388 pub fn edge_count(&self) -> usize {
389 self.edges.len()
390 }
391
392 pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
393 self.symbols.get(key)
394 }
395
396 pub fn get_reverse_deps(&self, path: &str, depth: usize) -> Vec<String> {
397 let mut result = Vec::new();
398 let mut visited = std::collections::HashSet::new();
399 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
400
401 while let Some((current, d)) = queue.pop() {
402 if d > depth || visited.contains(¤t) {
403 continue;
404 }
405 visited.insert(current.clone());
406 if current != path {
407 result.push(current.clone());
408 }
409
410 for edge in &self.edges {
411 if edge.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
412 queue.push((edge.from.clone(), d + 1));
413 }
414 }
415 }
416 result
417 }
418
419 pub fn get_forward_deps(&self, path: &str, depth: usize) -> Vec<String> {
422 let mut result = Vec::new();
423 let mut visited = std::collections::HashSet::new();
424 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
425
426 while let Some((current, d)) = queue.pop() {
427 if d > depth || visited.contains(¤t) {
428 continue;
429 }
430 visited.insert(current.clone());
431 if current != path {
432 result.push(current.clone());
433 }
434
435 for edge in &self.edges {
436 if edge.from == current && edge.kind == "import" && !visited.contains(&edge.to) {
437 queue.push((edge.to.clone(), d + 1));
438 }
439 }
440 }
441 result
442 }
443
444 pub fn get_related(&self, path: &str, depth: usize) -> Vec<String> {
445 let mut result = Vec::new();
446 let mut visited = std::collections::HashSet::new();
447 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
448
449 while let Some((current, d)) = queue.pop() {
450 if d > depth || visited.contains(¤t) {
451 continue;
452 }
453 visited.insert(current.clone());
454 if current != path {
455 result.push(current.clone());
456 }
457
458 for edge in &self.edges {
459 if edge.from == current && !visited.contains(&edge.to) {
460 queue.push((edge.to.clone(), d + 1));
461 }
462 if edge.to == current && !visited.contains(&edge.from) {
463 queue.push((edge.from.clone(), d + 1));
464 }
465 }
466 }
467 result
468 }
469}
470
471pub fn load_or_build(project_root: &str) -> ProjectIndex {
475 if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
476 return ProjectIndex::load(project_root).unwrap_or_else(|| ProjectIndex::new(project_root));
477 }
478
479 let root_abs = if project_root.trim().is_empty() || project_root == "." {
482 std::env::current_dir().ok().map_or_else(
483 || ".".to_string(),
484 |p| normalize_project_root(&p.to_string_lossy()),
485 )
486 } else {
487 normalize_project_root(project_root)
488 };
489
490 if !is_safe_scan_root(&root_abs) {
491 return ProjectIndex::new(&root_abs);
492 }
493
494 if let Some(idx) = ProjectIndex::load(&root_abs)
496 && !idx.files.is_empty()
497 {
498 if index_looks_stale(&idx, &root_abs) {
499 tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
500 return scan(&root_abs);
501 }
502 return idx;
503 }
504
505 if let Ok(cwd) = std::env::current_dir() {
507 let cwd_str = normalize_project_root(&cwd.to_string_lossy());
508 if cwd_str != root_abs
509 && cwd_str.starts_with(&root_abs)
510 && let Some(idx) = ProjectIndex::load(&cwd_str)
511 && !idx.files.is_empty()
512 {
513 if index_looks_stale(&idx, &cwd_str) {
514 return scan(&cwd_str);
515 }
516 return idx;
517 }
518 }
519
520 scan(&root_abs)
521}
522
523fn index_looks_stale(index: &ProjectIndex, root_abs: &str) -> bool {
524 if index.files.is_empty() {
525 return true;
526 }
527
528 if let Ok(scan_time) =
530 chrono::NaiveDateTime::parse_from_str(&index.last_scan, "%Y-%m-%d %H:%M:%S")
531 {
532 let cfg = crate::core::config::Config::load();
533 let effective_hours = cfg.archive_max_age_hours_effective();
534 let max_age = chrono::Duration::hours(effective_hours as i64);
535 let now = chrono::Local::now().naive_local();
536 if now.signed_duration_since(scan_time) > max_age {
537 tracing::info!(
538 "[graph_index: index is older than {}h — marking stale]",
539 effective_hours
540 );
541 return true;
542 }
543 }
544
545 const CONTAMINATION_MARKERS: &[&str] = &[
548 "Desktop/",
549 "Documents/",
550 "Downloads/",
551 "Pictures/",
552 "Music/",
553 "Videos/",
554 "Movies/",
555 "Library/",
556 ".cache/",
557 "snap/",
558 ];
559 let contaminated = index.files.keys().take(200).any(|rel| {
560 CONTAMINATION_MARKERS
561 .iter()
562 .any(|m| rel.starts_with(m) || rel.contains(&format!("/{m}")))
563 });
564 if contaminated {
565 tracing::warn!(
566 "[graph_index: index contains files from user directories (Desktop/Documents/...) — \
567 marking stale to force clean rebuild]"
568 );
569 return true;
570 }
571
572 let root_path = Path::new(root_abs);
573 let sample_size = index.files.len().min(20);
575 for rel in index.files.keys().take(sample_size) {
576 let rel = rel.trim_start_matches(['/', '\\']);
577 if rel.is_empty() {
578 continue;
579 }
580 let abs = root_path.join(rel);
581 if !abs.exists() {
582 return true;
583 }
584 }
585
586 if source_content_changed_since_index(index, root_abs) {
591 tracing::info!("[graph_index: source content changed since last scan — marking stale]");
592 return true;
593 }
594
595 false
596}
597
598fn index_file_mtime(root_abs: &str) -> Option<std::time::SystemTime> {
604 let dir = ProjectIndex::index_dir(root_abs)?;
605 for name in ["graph.meta.json", "graph.db"] {
606 if let Ok(meta) = std::fs::metadata(dir.join(name))
607 && let Ok(modified) = meta.modified()
608 {
609 return Some(modified);
610 }
611 }
612 None
613}
614
615fn source_content_changed_since_index(index: &ProjectIndex, root_abs: &str) -> bool {
628 let Some(index_mtime) = index_file_mtime(root_abs) else {
629 return false;
631 };
632 let index_filter = crate::core::index_filter::IndexFileFilter::effective();
635 let walker = ignore::WalkBuilder::new(root_abs)
636 .hidden(true)
637 .git_ignore(index_filter.respect_gitignore)
638 .git_global(index_filter.respect_gitignore)
639 .git_exclude(index_filter.respect_gitignore)
640 .require_git(false)
641 .max_depth(Some(20))
642 .filter_entry(crate::core::walk_filter::keep_entry)
643 .build();
644 const MAX_VISIT: usize = 50_000;
645 const MAX_CONFIRM_READS: usize = 4_000;
646 let mut visited = 0usize;
647 let mut confirm_reads = 0usize;
648 for entry in walker.filter_map(std::result::Result::ok) {
649 visited += 1;
650 if visited > MAX_VISIT {
651 break;
652 }
653 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
654 continue;
655 }
656 let path = entry.path();
657 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
658 if !is_indexable_ext(ext) {
659 continue;
660 }
661 let Ok(meta) = entry.metadata() else { continue };
663 let Ok(modified) = meta.modified() else {
664 continue;
665 };
666 if modified <= index_mtime {
667 continue;
668 }
669 let rel = make_relative(&path.to_string_lossy(), root_abs);
671 if index_filter.is_excluded(&rel.replace('\\', "/")) {
672 continue;
673 }
674 let Some(file_entry) = index.files.get(&rel) else {
675 return true;
677 };
678 confirm_reads += 1;
679 if confirm_reads > MAX_CONFIRM_READS {
680 return true;
682 }
683 match std::fs::read_to_string(path) {
684 Ok(content) if compute_hash(&content) == file_entry.hash => {}
686 _ => return true,
688 }
689 }
690 false
691}
692
693pub fn purge_index(project_root: &str) {
699 if let Some(dir) = ProjectIndex::index_dir(project_root) {
700 for name in [
701 "graph.db",
702 "graph.db-wal",
703 "graph.db-shm",
704 "graph.meta.json",
705 "index.json.zst",
706 "index.json",
707 "call_graph.json.zst",
708 ] {
709 let _ = std::fs::remove_file(dir.join(name));
710 }
711 }
712}
713
714pub fn scan(project_root: &str) -> ProjectIndex {
715 scan_inner(project_root).0
716}
717
718pub fn scan_with_content_cache(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
719 scan_inner(project_root)
720}
721
722fn scan_inner(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
723 if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
724 tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
725 return (ProjectIndex::new(project_root), HashMap::new());
726 }
727
728 let project_root = normalize_project_root(project_root);
729
730 if !is_safe_scan_root(&project_root) {
731 tracing::warn!("[graph_index: scan aborted for unsafe root {project_root}]");
732 return (ProjectIndex::new(&project_root), HashMap::new());
733 }
734
735 let lock_name = format!(
736 "graph-idx-{}",
737 &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
738 );
739 let _lock = crate::core::startup_guard::try_acquire_lock(
740 &lock_name,
741 std::time::Duration::from_millis(800),
742 std::time::Duration::from_mins(3),
743 );
744 if _lock.is_none() {
745 tracing::info!(
746 "[graph_index: another process is scanning {project_root} — returning cached or empty]"
747 );
748 return (
749 ProjectIndex::load(&project_root).unwrap_or_else(|| ProjectIndex::new(&project_root)),
750 HashMap::new(),
751 );
752 }
753
754 let existing = ProjectIndex::load(&project_root);
755 let mut index = ProjectIndex::new(&project_root);
756
757 let previous_symbols = existing
761 .as_ref()
762 .map(previous_symbols_by_file)
763 .unwrap_or_default();
764
765 let cfg = crate::core::config::Config::load();
766 let index_filter = crate::core::index_filter::IndexFileFilter::resolve(&cfg);
768
769 let walker = ignore::WalkBuilder::new(&project_root)
770 .hidden(true)
771 .git_ignore(index_filter.respect_gitignore)
772 .git_global(index_filter.respect_gitignore)
773 .git_exclude(index_filter.respect_gitignore)
774 .require_git(false)
775 .max_depth(Some(20))
776 .filter_entry(crate::core::walk_filter::keep_entry)
777 .build();
778
779 let extra_ignores: Vec<glob::Pattern> = cfg
780 .extra_ignore_patterns
781 .iter()
782 .filter_map(|p| glob::Pattern::new(p).ok())
783 .collect();
784
785 let mut scanned = 0usize;
786 let mut reused = 0usize;
787 let mut entries_visited = 0usize;
788 let mut content_cache: HashMap<String, String> = HashMap::new();
789 let mut content_cache_bytes: usize = 0;
790 const CONTENT_CACHE_MAX_BYTES: usize = 256 * 1024 * 1024; let max_files = if cfg.graph_index_max_files == 0 {
792 usize::MAX } else {
794 cfg.graph_index_max_files as usize
795 };
796 const MAX_ENTRIES_VISITED: usize = 500_000;
797 const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024; const SCAN_BATCH_FILES: usize = 500;
800 const SCAN_MIN_BATCH_FILES: usize = 1;
801 const SCAN_EST_TRANSIENT_PER_FILE: u64 = 192 * 1024;
805 let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(5);
806
807 let mut targets: Vec<(String, String, String)> = Vec::new();
815 for entry in walker.filter_map(std::result::Result::ok) {
816 entries_visited += 1;
817 if entries_visited > MAX_ENTRIES_VISITED {
818 tracing::warn!(
819 "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
820 runaway traversal. Indexed {} files so far.]",
821 targets.len()
822 );
823 break;
824 }
825 if entries_visited.is_multiple_of(5000) {
826 if std::time::Instant::now() > scan_deadline {
827 tracing::warn!(
828 "[graph_index: scan timeout (120s) after {entries_visited} entries — \
829 saving partial index with {} files]",
830 targets.len()
831 );
832 break;
833 }
834 if crate::core::memory_guard::abort_requested() {
835 tracing::warn!(
836 "[graph_index: memory pressure abort after {entries_visited} entries — \
837 saving partial index with {} files]",
838 targets.len()
839 );
840 break;
841 }
842 if crate::core::memory_guard::is_under_pressure() {
843 tracing::warn!(
844 "[graph_index: memory pressure detected at {entries_visited} entries — \
845 stopping scan with {} files]",
846 targets.len()
847 );
848 break;
849 }
850 if let Some(ref g) = _lock {
851 g.touch();
852 }
853 }
854
855 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
856 continue;
857 }
858
859 if entry.path_is_symlink() {
860 continue;
861 }
862 let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
863
864 if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
865 continue;
866 }
867
868 if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
869 if meta.file_type().is_symlink() || !meta.is_file() {
870 continue;
871 }
872 if meta.len() > MAX_FILE_SIZE_BYTES {
873 tracing::debug!(
874 "[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
875 meta.len() as f64 / 1_048_576.0,
876 MAX_FILE_SIZE_BYTES / (1024 * 1024),
877 );
878 continue;
879 }
880 }
881
882 let ext = Path::new(&file_path)
883 .extension()
884 .and_then(|e| e.to_str())
885 .unwrap_or("");
886
887 if !is_indexable_ext(ext) {
888 continue;
889 }
890
891 let rel = make_relative(&file_path, &project_root);
892 if extra_ignores.iter().any(|p| p.matches(&rel)) {
893 continue;
894 }
895 if index_filter.is_excluded(&rel.replace('\\', "/")) {
896 continue;
897 }
898
899 if max_files != usize::MAX && targets.len() >= max_files {
900 tracing::info!(
901 "[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
902 max_files
903 );
904 break;
905 }
906
907 let ext = ext.to_string();
909 targets.push((file_path, rel, ext));
910 }
911
912 let target_rels: Vec<String> = targets.iter().map(|(_, r, _)| r.clone()).collect();
916 let admission = crate::core::index_admission::admit_files(
917 crate::core::index_admission::BuildKind::GraphScan,
918 std::path::Path::new(&project_root),
919 &target_rels,
920 );
921 let parallel = admission.parallel_ok && !crate::core::memory_guard::is_under_pressure();
922 let mut files_done = 0;
923 while files_done < targets.len() {
924 if crate::core::memory_guard::abort_requested() {
925 tracing::warn!(
926 "[graph_index: aborting scan after {files_done} files due to critical memory pressure]"
927 );
928 break;
929 }
930 if crate::core::memory_guard::is_under_pressure() {
931 tracing::warn!(
932 "[graph_index: stopping scan after {files_done} files due to memory pressure]"
933 );
934 break;
935 }
936 let batch_size = crate::core::memory_guard::adaptive_batch_size(
937 SCAN_MIN_BATCH_FILES,
938 SCAN_BATCH_FILES,
939 SCAN_EST_TRANSIENT_PER_FILE,
940 );
941 let batch_end = (files_done + batch_size).min(targets.len());
942 let results = process_scan_targets(
943 &targets[files_done..batch_end],
944 &previous_symbols,
945 existing.as_ref(),
946 parallel,
947 );
948 for r in results {
949 if r.reused {
950 reused += 1;
951 } else {
952 scanned += 1;
953 }
954 index.files.insert(r.rel.clone(), r.file_entry);
955 for (key, sym) in r.symbols {
956 index.symbols.insert(key, sym);
957 }
958 if content_cache_bytes < CONTENT_CACHE_MAX_BYTES {
959 content_cache_bytes += r.content.len();
960 content_cache.insert(r.rel, r.content);
961 }
962 }
963 files_done = batch_end;
964 crate::core::memory_guard::jemalloc_purge();
965 }
966
967 index.rebuild_interner();
968 build_edges_cached(&mut index, &content_cache);
969
970 if let Err(e) = index.save() {
971 tracing::warn!("could not save graph index: {e}");
972 }
973
974 tracing::debug!(
975 "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
976 index.file_count(),
977 scanned,
978 reused,
979 index.symbol_count(),
980 index.edge_count()
981 );
982
983 (index, content_cache)
984}
985
986type PreviousSymbols<'a> = HashMap<&'a str, Vec<(&'a str, &'a SymbolEntry)>>;
990
991fn previous_symbols_by_file(index: &ProjectIndex) -> PreviousSymbols<'_> {
992 let mut by_file: PreviousSymbols<'_> = HashMap::new();
993 for (key, symbol) in &index.symbols {
994 by_file
995 .entry(symbol.file.as_str())
996 .or_default()
997 .push((key.as_str(), symbol));
998 }
999 by_file
1000}
1001
1002#[derive(Debug, PartialEq)]
1005struct ScanFileResult {
1006 rel: String,
1007 file_entry: FileEntry,
1008 symbols: Vec<(String, SymbolEntry)>,
1010 content: String,
1011 reused: bool,
1012}
1013
1014fn process_scan_file(
1018 file_path: &str,
1019 rel: &str,
1020 ext: &str,
1021 previous_symbols: &PreviousSymbols<'_>,
1022 existing: Option<&ProjectIndex>,
1023) -> Option<ScanFileResult> {
1024 if crate::core::memory_guard::abort_requested() {
1025 return None;
1026 }
1027 let content = std::fs::read_to_string(file_path).ok()?;
1028 let hash = compute_hash(&content);
1029
1030 if let Some(old_entry) = existing.and_then(|p| p.files.get(rel))
1034 && old_entry.hash == hash
1035 {
1036 let symbols = previous_symbols
1037 .get(rel)
1038 .into_iter()
1039 .flatten()
1040 .map(|(key, symbol)| ((*key).to_string(), (*symbol).clone()))
1041 .collect();
1042 return Some(ScanFileResult {
1043 rel: rel.to_string(),
1044 file_entry: old_entry.clone(),
1045 symbols,
1046 content,
1047 reused: true,
1048 });
1049 }
1050
1051 if crate::core::memory_guard::abort_requested() {
1052 return None;
1053 }
1054
1055 let sigs = signatures::extract_signatures(&content, ext);
1056 let line_count = content.lines().count();
1057 let token_count = crate::core::tokens::count_tokens(&content);
1058 let summary = extract_summary(&content);
1059
1060 let exports: Vec<String> = sigs
1061 .iter()
1062 .filter(|s| s.is_exported)
1063 .map(|s| s.name.clone())
1064 .collect();
1065
1066 let file_entry = FileEntry {
1067 path: rel.to_string(),
1068 hash,
1069 language: ext.to_string(),
1070 line_count,
1071 token_count,
1072 exports,
1073 summary,
1074 };
1075
1076 let symbols: Vec<(String, SymbolEntry)> = sigs
1077 .iter()
1078 .map(|sig| {
1079 let (start, end) = sig
1080 .start_line
1081 .zip(sig.end_line)
1082 .unwrap_or_else(|| find_symbol_range(&content, sig));
1083 let key = format!("{rel}::{}", sig.name);
1084 (
1085 key,
1086 SymbolEntry {
1087 file: rel.to_string(),
1088 name: sig.name.clone(),
1089 kind: sig.kind.to_string(),
1090 start_line: start,
1091 end_line: end,
1092 is_exported: sig.is_exported,
1093 },
1094 )
1095 })
1096 .collect();
1097
1098 Some(ScanFileResult {
1099 rel: rel.to_string(),
1100 file_entry,
1101 symbols,
1102 content,
1103 reused: false,
1104 })
1105}
1106
1107fn process_scan_targets(
1112 targets: &[(String, String, String)],
1113 previous_symbols: &PreviousSymbols<'_>,
1114 existing: Option<&ProjectIndex>,
1115 parallel: bool,
1116) -> Vec<ScanFileResult> {
1117 if parallel {
1118 targets
1119 .par_iter()
1120 .filter_map(|(file_path, rel, ext)| {
1121 process_scan_file(file_path, rel, ext, previous_symbols, existing)
1122 })
1123 .collect()
1124 } else {
1125 targets
1126 .iter()
1127 .filter_map(|(file_path, rel, ext)| {
1128 process_scan_file(file_path, rel, ext, previous_symbols, existing)
1129 })
1130 .collect()
1131 }
1132}
1133
1134fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
1135 let lines: Vec<&str> = content.lines().collect();
1136 let mut start = 0;
1137
1138 for (i, line) in lines.iter().enumerate() {
1139 if line.contains(&sig.name) {
1140 let trimmed = line.trim();
1141 let is_def = trimmed.starts_with("fn ")
1142 || trimmed.starts_with("pub fn ")
1143 || trimmed.starts_with("pub(crate) fn ")
1144 || trimmed.starts_with("async fn ")
1145 || trimmed.starts_with("pub async fn ")
1146 || trimmed.starts_with("struct ")
1147 || trimmed.starts_with("pub struct ")
1148 || trimmed.starts_with("enum ")
1149 || trimmed.starts_with("pub enum ")
1150 || trimmed.starts_with("trait ")
1151 || trimmed.starts_with("pub trait ")
1152 || trimmed.starts_with("impl ")
1153 || trimmed.starts_with("class ")
1154 || trimmed.starts_with("export class ")
1155 || trimmed.starts_with("export function ")
1156 || trimmed.starts_with("export async function ")
1157 || trimmed.starts_with("function ")
1158 || trimmed.starts_with("async function ")
1159 || trimmed.starts_with("def ")
1160 || trimmed.starts_with("async def ")
1161 || trimmed.starts_with("func ")
1162 || trimmed.starts_with("interface ")
1163 || trimmed.starts_with("export interface ")
1164 || trimmed.starts_with("type ")
1165 || trimmed.starts_with("export type ")
1166 || trimmed.starts_with("const ")
1167 || trimmed.starts_with("export const ")
1168 || trimmed.starts_with("fun ")
1169 || trimmed.starts_with("private fun ")
1170 || trimmed.starts_with("public fun ")
1171 || trimmed.starts_with("internal fun ")
1172 || trimmed.starts_with("class ")
1173 || trimmed.starts_with("data class ")
1174 || trimmed.starts_with("sealed class ")
1175 || trimmed.starts_with("sealed interface ")
1176 || trimmed.starts_with("enum class ")
1177 || trimmed.starts_with("object ")
1178 || trimmed.starts_with("private object ")
1179 || trimmed.starts_with("interface ")
1180 || trimmed.starts_with("typealias ")
1181 || trimmed.starts_with("private typealias ");
1182 if is_def {
1183 start = i + 1;
1184 break;
1185 }
1186 }
1187 }
1188
1189 if start == 0 {
1190 return (1, lines.len().min(20));
1191 }
1192
1193 let base_indent = lines
1194 .get(start - 1)
1195 .map_or(0, |l| l.len() - l.trim_start().len());
1196
1197 let mut end = start;
1198 let mut brace_depth: i32 = 0;
1199 let mut found_open = false;
1200
1201 for (i, line) in lines.iter().enumerate().skip(start - 1) {
1202 for ch in line.chars() {
1203 if ch == '{' {
1204 brace_depth += 1;
1205 found_open = true;
1206 } else if ch == '}' {
1207 brace_depth -= 1;
1208 }
1209 }
1210
1211 end = i + 1;
1212
1213 if found_open && brace_depth <= 0 {
1214 break;
1215 }
1216
1217 if !found_open && i > start {
1218 let indent = line.len() - line.trim_start().len();
1219 if indent <= base_indent && !line.trim().is_empty() && i > start {
1220 end = i;
1221 break;
1222 }
1223 }
1224
1225 if end - start > 200 {
1226 break;
1227 }
1228 }
1229
1230 (start, end)
1231}
1232
1233fn extract_summary(content: &str) -> String {
1234 for line in content.lines().take(20) {
1235 let trimmed = line.trim();
1236 if trimmed.is_empty()
1237 || trimmed.starts_with("//")
1238 || trimmed.starts_with('#')
1239 || trimmed.starts_with("/*")
1240 || trimmed.starts_with('*')
1241 || trimmed.starts_with("use ")
1242 || trimmed.starts_with("import ")
1243 || trimmed.starts_with("from ")
1244 || trimmed.starts_with("require(")
1245 || trimmed.starts_with("package ")
1246 {
1247 continue;
1248 }
1249 return trimmed.chars().take(120).collect();
1250 }
1251 String::new()
1252}
1253
1254fn compute_hash(content: &str) -> String {
1255 use std::collections::hash_map::DefaultHasher;
1256 use std::hash::{Hash, Hasher};
1257
1258 let mut hasher = DefaultHasher::new();
1259 content.hash(&mut hasher);
1260 format!("{:016x}", hasher.finish())
1261}
1262
1263#[cfg(test)]
1264fn short_hash(input: &str) -> String {
1265 use std::collections::hash_map::DefaultHasher;
1266 use std::hash::{Hash, Hasher};
1267
1268 let mut hasher = DefaultHasher::new();
1269 input.hash(&mut hasher);
1270 format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1271}
1272
1273fn make_relative(path: &str, root: &str) -> String {
1274 graph_relative_key(path, root)
1275}
1276
1277fn is_indexable_ext(ext: &str) -> bool {
1278 crate::core::language_capabilities::is_indexable_ext(ext)
1279}
1280
1281#[cfg(test)]
1282fn kotlin_package_name(content: &str) -> Option<String> {
1283 content.lines().map(str::trim).find_map(|line| {
1284 line.strip_prefix("package ")
1285 .map(|rest| rest.trim().trim_end_matches(';').to_string())
1286 })
1287}