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