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