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 max_files = if cfg.graph_index_max_files == 0 {
778 usize::MAX } else {
780 cfg.graph_index_max_files as usize
781 };
782 const MAX_ENTRIES_VISITED: usize = 500_000;
783 const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024; const SCAN_BATCH_FILES: usize = 2_000;
787 let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(5);
788
789 let mut targets: Vec<(String, String, String)> = Vec::new();
797 for entry in walker.filter_map(std::result::Result::ok) {
798 entries_visited += 1;
799 if entries_visited > MAX_ENTRIES_VISITED {
800 tracing::warn!(
801 "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
802 runaway traversal. Indexed {} files so far.]",
803 targets.len()
804 );
805 break;
806 }
807 if entries_visited.is_multiple_of(5000) {
808 if std::time::Instant::now() > scan_deadline {
809 tracing::warn!(
810 "[graph_index: scan timeout (120s) after {entries_visited} entries — \
811 saving partial index with {} files]",
812 targets.len()
813 );
814 break;
815 }
816 if crate::core::memory_guard::abort_requested() {
817 tracing::warn!(
818 "[graph_index: memory pressure abort after {entries_visited} entries — \
819 saving partial index with {} files]",
820 targets.len()
821 );
822 break;
823 }
824 if crate::core::memory_guard::is_under_pressure() {
825 tracing::warn!(
826 "[graph_index: memory pressure detected at {entries_visited} entries — \
827 stopping scan with {} files]",
828 targets.len()
829 );
830 break;
831 }
832 if let Some(ref g) = _lock {
833 g.touch();
834 }
835 }
836
837 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
838 continue;
839 }
840
841 if entry.path_is_symlink() {
842 continue;
843 }
844 let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
845
846 if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
847 continue;
848 }
849
850 if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
851 if meta.file_type().is_symlink() || !meta.is_file() {
852 continue;
853 }
854 if meta.len() > MAX_FILE_SIZE_BYTES {
855 tracing::debug!(
856 "[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
857 meta.len() as f64 / 1_048_576.0,
858 MAX_FILE_SIZE_BYTES / (1024 * 1024),
859 );
860 continue;
861 }
862 }
863
864 let ext = Path::new(&file_path)
865 .extension()
866 .and_then(|e| e.to_str())
867 .unwrap_or("");
868
869 if !is_indexable_ext(ext) {
870 continue;
871 }
872
873 let rel = make_relative(&file_path, &project_root);
874 if extra_ignores.iter().any(|p| p.matches(&rel)) {
875 continue;
876 }
877 if index_filter.is_excluded(&rel.replace('\\', "/")) {
878 continue;
879 }
880
881 if max_files != usize::MAX && targets.len() >= max_files {
882 tracing::info!(
883 "[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
884 max_files
885 );
886 break;
887 }
888
889 let ext = ext.to_string();
891 targets.push((file_path, rel, ext));
892 }
893
894 let parallel = !crate::core::memory_guard::is_under_pressure();
901 for (batch_no, batch) in targets.chunks(SCAN_BATCH_FILES).enumerate() {
902 if batch_no > 0 {
903 if crate::core::memory_guard::abort_requested() {
904 tracing::warn!(
905 "[graph_index: aborting scan after {} files due to critical memory pressure]",
906 batch_no * SCAN_BATCH_FILES
907 );
908 break;
909 }
910 if crate::core::memory_guard::is_under_pressure() {
911 tracing::warn!(
912 "[graph_index: stopping scan after {} files due to memory pressure]",
913 batch_no * SCAN_BATCH_FILES
914 );
915 break;
916 }
917 }
918 let results = process_scan_targets(batch, &old_files, existing.as_ref(), parallel);
919 for r in results {
920 if r.reused {
921 reused += 1;
922 } else {
923 scanned += 1;
924 }
925 index.files.insert(r.rel.clone(), r.file_entry);
926 for (key, sym) in r.symbols {
927 index.symbols.insert(key, sym);
928 }
929 content_cache.insert(r.rel, r.content);
930 }
931 }
932
933 build_edges_cached(&mut index, &content_cache);
934
935 if let Err(e) = index.save() {
936 tracing::warn!("could not save graph index: {e}");
937 }
938
939 tracing::debug!(
940 "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
941 index.file_count(),
942 scanned,
943 reused,
944 index.symbol_count(),
945 index.edge_count()
946 );
947
948 (index, content_cache)
949}
950
951type OldFileSymbols = HashMap<String, (String, Vec<(String, SymbolEntry)>)>;
954
955#[derive(Debug, PartialEq)]
958struct ScanFileResult {
959 rel: String,
960 file_entry: FileEntry,
961 symbols: Vec<(String, SymbolEntry)>,
963 content: String,
964 reused: bool,
965}
966
967fn process_scan_file(
971 file_path: &str,
972 rel: &str,
973 ext: &str,
974 old_files: &OldFileSymbols,
975 existing: Option<&ProjectIndex>,
976) -> Option<ScanFileResult> {
977 let content = std::fs::read_to_string(file_path).ok()?;
978 let hash = compute_hash(&content);
979
980 if let Some((old_hash, old_syms)) = old_files.get(rel)
982 && *old_hash == hash
983 && let Some(old_entry) = existing.and_then(|p| p.files.get(rel))
984 {
985 return Some(ScanFileResult {
986 rel: rel.to_string(),
987 file_entry: old_entry.clone(),
988 symbols: old_syms.clone(),
989 content,
990 reused: true,
991 });
992 }
993
994 let sigs = signatures::extract_signatures(&content, ext);
995 let line_count = content.lines().count();
996 let token_count = crate::core::tokens::count_tokens(&content);
997 let summary = extract_summary(&content);
998
999 let exports: Vec<String> = sigs
1000 .iter()
1001 .filter(|s| s.is_exported)
1002 .map(|s| s.name.clone())
1003 .collect();
1004
1005 let file_entry = FileEntry {
1006 path: rel.to_string(),
1007 hash,
1008 language: ext.to_string(),
1009 line_count,
1010 token_count,
1011 exports,
1012 summary,
1013 };
1014
1015 let symbols: Vec<(String, SymbolEntry)> = sigs
1016 .iter()
1017 .map(|sig| {
1018 let (start, end) = sig
1019 .start_line
1020 .zip(sig.end_line)
1021 .unwrap_or_else(|| find_symbol_range(&content, sig));
1022 let key = format!("{rel}::{}", sig.name);
1023 (
1024 key,
1025 SymbolEntry {
1026 file: rel.to_string(),
1027 name: sig.name.clone(),
1028 kind: sig.kind.to_string(),
1029 start_line: start,
1030 end_line: end,
1031 is_exported: sig.is_exported,
1032 },
1033 )
1034 })
1035 .collect();
1036
1037 Some(ScanFileResult {
1038 rel: rel.to_string(),
1039 file_entry,
1040 symbols,
1041 content,
1042 reused: false,
1043 })
1044}
1045
1046fn process_scan_targets(
1051 targets: &[(String, String, String)],
1052 old_files: &OldFileSymbols,
1053 existing: Option<&ProjectIndex>,
1054 parallel: bool,
1055) -> Vec<ScanFileResult> {
1056 if parallel {
1057 targets
1058 .par_iter()
1059 .filter_map(|(file_path, rel, ext)| {
1060 process_scan_file(file_path, rel, ext, old_files, existing)
1061 })
1062 .collect()
1063 } else {
1064 targets
1065 .iter()
1066 .filter_map(|(file_path, rel, ext)| {
1067 process_scan_file(file_path, rel, ext, old_files, existing)
1068 })
1069 .collect()
1070 }
1071}
1072
1073fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
1074 let lines: Vec<&str> = content.lines().collect();
1075 let mut start = 0;
1076
1077 for (i, line) in lines.iter().enumerate() {
1078 if line.contains(&sig.name) {
1079 let trimmed = line.trim();
1080 let is_def = trimmed.starts_with("fn ")
1081 || trimmed.starts_with("pub fn ")
1082 || trimmed.starts_with("pub(crate) fn ")
1083 || trimmed.starts_with("async fn ")
1084 || trimmed.starts_with("pub async fn ")
1085 || trimmed.starts_with("struct ")
1086 || trimmed.starts_with("pub struct ")
1087 || trimmed.starts_with("enum ")
1088 || trimmed.starts_with("pub enum ")
1089 || trimmed.starts_with("trait ")
1090 || trimmed.starts_with("pub trait ")
1091 || trimmed.starts_with("impl ")
1092 || trimmed.starts_with("class ")
1093 || trimmed.starts_with("export class ")
1094 || trimmed.starts_with("export function ")
1095 || trimmed.starts_with("export async function ")
1096 || trimmed.starts_with("function ")
1097 || trimmed.starts_with("async function ")
1098 || trimmed.starts_with("def ")
1099 || trimmed.starts_with("async def ")
1100 || trimmed.starts_with("func ")
1101 || trimmed.starts_with("interface ")
1102 || trimmed.starts_with("export interface ")
1103 || trimmed.starts_with("type ")
1104 || trimmed.starts_with("export type ")
1105 || trimmed.starts_with("const ")
1106 || trimmed.starts_with("export const ")
1107 || trimmed.starts_with("fun ")
1108 || trimmed.starts_with("private fun ")
1109 || trimmed.starts_with("public fun ")
1110 || trimmed.starts_with("internal fun ")
1111 || trimmed.starts_with("class ")
1112 || trimmed.starts_with("data class ")
1113 || trimmed.starts_with("sealed class ")
1114 || trimmed.starts_with("sealed interface ")
1115 || trimmed.starts_with("enum class ")
1116 || trimmed.starts_with("object ")
1117 || trimmed.starts_with("private object ")
1118 || trimmed.starts_with("interface ")
1119 || trimmed.starts_with("typealias ")
1120 || trimmed.starts_with("private typealias ");
1121 if is_def {
1122 start = i + 1;
1123 break;
1124 }
1125 }
1126 }
1127
1128 if start == 0 {
1129 return (1, lines.len().min(20));
1130 }
1131
1132 let base_indent = lines
1133 .get(start - 1)
1134 .map_or(0, |l| l.len() - l.trim_start().len());
1135
1136 let mut end = start;
1137 let mut brace_depth: i32 = 0;
1138 let mut found_open = false;
1139
1140 for (i, line) in lines.iter().enumerate().skip(start - 1) {
1141 for ch in line.chars() {
1142 if ch == '{' {
1143 brace_depth += 1;
1144 found_open = true;
1145 } else if ch == '}' {
1146 brace_depth -= 1;
1147 }
1148 }
1149
1150 end = i + 1;
1151
1152 if found_open && brace_depth <= 0 {
1153 break;
1154 }
1155
1156 if !found_open && i > start {
1157 let indent = line.len() - line.trim_start().len();
1158 if indent <= base_indent && !line.trim().is_empty() && i > start {
1159 end = i;
1160 break;
1161 }
1162 }
1163
1164 if end - start > 200 {
1165 break;
1166 }
1167 }
1168
1169 (start, end)
1170}
1171
1172fn extract_summary(content: &str) -> String {
1173 for line in content.lines().take(20) {
1174 let trimmed = line.trim();
1175 if trimmed.is_empty()
1176 || trimmed.starts_with("//")
1177 || trimmed.starts_with('#')
1178 || trimmed.starts_with("/*")
1179 || trimmed.starts_with('*')
1180 || trimmed.starts_with("use ")
1181 || trimmed.starts_with("import ")
1182 || trimmed.starts_with("from ")
1183 || trimmed.starts_with("require(")
1184 || trimmed.starts_with("package ")
1185 {
1186 continue;
1187 }
1188 return trimmed.chars().take(120).collect();
1189 }
1190 String::new()
1191}
1192
1193fn compute_hash(content: &str) -> String {
1194 use std::collections::hash_map::DefaultHasher;
1195 use std::hash::{Hash, Hasher};
1196
1197 let mut hasher = DefaultHasher::new();
1198 content.hash(&mut hasher);
1199 format!("{:016x}", hasher.finish())
1200}
1201
1202#[cfg(test)]
1203fn short_hash(input: &str) -> String {
1204 use std::collections::hash_map::DefaultHasher;
1205 use std::hash::{Hash, Hasher};
1206
1207 let mut hasher = DefaultHasher::new();
1208 input.hash(&mut hasher);
1209 format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1210}
1211
1212fn make_relative(path: &str, root: &str) -> String {
1213 graph_relative_key(path, root)
1214}
1215
1216fn is_indexable_ext(ext: &str) -> bool {
1217 crate::core::language_capabilities::is_indexable_ext(ext)
1218}
1219
1220#[cfg(test)]
1221fn kotlin_package_name(content: &str) -> Option<String> {
1222 content.lines().map(str::trim).find_map(|line| {
1223 line.strip_prefix("package ")
1224 .map(|rest| rest.trim().trim_end_matches(';').to_string())
1225 })
1226}