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 walker = ignore::WalkBuilder::new(root_abs)
612 .hidden(true)
613 .git_ignore(true)
614 .git_global(true)
615 .git_exclude(true)
616 .require_git(false)
617 .max_depth(Some(20))
618 .filter_entry(crate::core::walk_filter::keep_entry)
619 .build();
620 const MAX_VISIT: usize = 50_000;
621 const MAX_CONFIRM_READS: usize = 4_000;
622 let mut visited = 0usize;
623 let mut confirm_reads = 0usize;
624 for entry in walker.filter_map(std::result::Result::ok) {
625 visited += 1;
626 if visited > MAX_VISIT {
627 break;
628 }
629 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
630 continue;
631 }
632 let path = entry.path();
633 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
634 if !is_indexable_ext(ext) {
635 continue;
636 }
637 let Ok(meta) = entry.metadata() else { continue };
639 let Ok(modified) = meta.modified() else {
640 continue;
641 };
642 if modified <= index_mtime {
643 continue;
644 }
645 let rel = make_relative(&path.to_string_lossy(), root_abs);
647 let Some(file_entry) = index.files.get(&rel) else {
648 return true;
650 };
651 confirm_reads += 1;
652 if confirm_reads > MAX_CONFIRM_READS {
653 return true;
655 }
656 match std::fs::read_to_string(path) {
657 Ok(content) if compute_hash(&content) == file_entry.hash => {}
659 _ => return true,
661 }
662 }
663 false
664}
665
666pub fn purge_index(project_root: &str) {
672 if let Some(dir) = ProjectIndex::index_dir(project_root) {
673 for name in [
674 "graph.db",
675 "graph.db-wal",
676 "graph.db-shm",
677 "graph.meta.json",
678 "index.json.zst",
679 "index.json",
680 "call_graph.json.zst",
681 ] {
682 let _ = std::fs::remove_file(dir.join(name));
683 }
684 }
685}
686
687pub fn scan(project_root: &str) -> ProjectIndex {
688 scan_inner(project_root).0
689}
690
691pub fn scan_with_content_cache(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
692 scan_inner(project_root)
693}
694
695fn scan_inner(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
696 if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
697 tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
698 return (ProjectIndex::new(project_root), HashMap::new());
699 }
700
701 let project_root = normalize_project_root(project_root);
702
703 if !is_safe_scan_root(&project_root) {
704 tracing::warn!("[graph_index: scan aborted for unsafe root {project_root}]");
705 return (ProjectIndex::new(&project_root), HashMap::new());
706 }
707
708 let lock_name = format!(
709 "graph-idx-{}",
710 &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
711 );
712 let _lock = crate::core::startup_guard::try_acquire_lock(
713 &lock_name,
714 std::time::Duration::from_millis(800),
715 std::time::Duration::from_mins(3),
716 );
717 if _lock.is_none() {
718 tracing::info!(
719 "[graph_index: another process is scanning {project_root} — returning cached or empty]"
720 );
721 return (
722 ProjectIndex::load(&project_root).unwrap_or_else(|| ProjectIndex::new(&project_root)),
723 HashMap::new(),
724 );
725 }
726
727 let existing = ProjectIndex::load(&project_root);
728 let mut index = ProjectIndex::new(&project_root);
729
730 let old_files: OldFileSymbols = if let Some(ref prev) = existing {
731 prev.files
732 .iter()
733 .map(|(path, entry)| {
734 let syms: Vec<(String, SymbolEntry)> = prev
735 .symbols
736 .iter()
737 .filter(|(_, s)| s.file == *path)
738 .map(|(k, v)| (k.clone(), v.clone()))
739 .collect();
740 (path.clone(), (entry.hash.clone(), syms))
741 })
742 .collect()
743 } else {
744 HashMap::new()
745 };
746
747 let walker = ignore::WalkBuilder::new(&project_root)
748 .hidden(true)
749 .git_ignore(true)
750 .git_global(true)
751 .git_exclude(true)
752 .require_git(false)
753 .max_depth(Some(20))
754 .filter_entry(crate::core::walk_filter::keep_entry)
755 .build();
756
757 let cfg = crate::core::config::Config::load();
758 let extra_ignores: Vec<glob::Pattern> = cfg
759 .extra_ignore_patterns
760 .iter()
761 .filter_map(|p| glob::Pattern::new(p).ok())
762 .collect();
763
764 let mut scanned = 0usize;
765 let mut reused = 0usize;
766 let mut entries_visited = 0usize;
767 let mut content_cache: HashMap<String, String> = HashMap::new();
768 let max_files = if cfg.graph_index_max_files == 0 {
769 usize::MAX } else {
771 cfg.graph_index_max_files as usize
772 };
773 const MAX_ENTRIES_VISITED: usize = 500_000;
774 const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024; const SCAN_BATCH_FILES: usize = 2_000;
778 let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(5);
779
780 let mut targets: Vec<(String, String, String)> = Vec::new();
788 for entry in walker.filter_map(std::result::Result::ok) {
789 entries_visited += 1;
790 if entries_visited > MAX_ENTRIES_VISITED {
791 tracing::warn!(
792 "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
793 runaway traversal. Indexed {} files so far.]",
794 targets.len()
795 );
796 break;
797 }
798 if entries_visited.is_multiple_of(5000) {
799 if std::time::Instant::now() > scan_deadline {
800 tracing::warn!(
801 "[graph_index: scan timeout (120s) after {entries_visited} entries — \
802 saving partial index with {} files]",
803 targets.len()
804 );
805 break;
806 }
807 if crate::core::memory_guard::abort_requested() {
808 tracing::warn!(
809 "[graph_index: memory pressure abort after {entries_visited} entries — \
810 saving partial index with {} files]",
811 targets.len()
812 );
813 break;
814 }
815 if crate::core::memory_guard::is_under_pressure() {
816 tracing::warn!(
817 "[graph_index: memory pressure detected at {entries_visited} entries — \
818 stopping scan with {} files]",
819 targets.len()
820 );
821 break;
822 }
823 if let Some(ref g) = _lock {
824 g.touch();
825 }
826 }
827
828 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
829 continue;
830 }
831
832 if entry.path_is_symlink() {
833 continue;
834 }
835 let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
836
837 if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
838 continue;
839 }
840
841 if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
842 if meta.file_type().is_symlink() || !meta.is_file() {
843 continue;
844 }
845 if meta.len() > MAX_FILE_SIZE_BYTES {
846 tracing::debug!(
847 "[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
848 meta.len() as f64 / 1_048_576.0,
849 MAX_FILE_SIZE_BYTES / (1024 * 1024),
850 );
851 continue;
852 }
853 }
854
855 let ext = Path::new(&file_path)
856 .extension()
857 .and_then(|e| e.to_str())
858 .unwrap_or("");
859
860 if !is_indexable_ext(ext) {
861 continue;
862 }
863
864 let rel = make_relative(&file_path, &project_root);
865 if extra_ignores.iter().any(|p| p.matches(&rel)) {
866 continue;
867 }
868
869 if max_files != usize::MAX && targets.len() >= max_files {
870 tracing::info!(
871 "[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
872 max_files
873 );
874 break;
875 }
876
877 let ext = ext.to_string();
879 targets.push((file_path, rel, ext));
880 }
881
882 let parallel = !crate::core::memory_guard::is_under_pressure();
889 for (batch_no, batch) in targets.chunks(SCAN_BATCH_FILES).enumerate() {
890 if batch_no > 0 {
891 if crate::core::memory_guard::abort_requested() {
892 tracing::warn!(
893 "[graph_index: aborting scan after {} files due to critical memory pressure]",
894 batch_no * SCAN_BATCH_FILES
895 );
896 break;
897 }
898 if crate::core::memory_guard::is_under_pressure() {
899 tracing::warn!(
900 "[graph_index: stopping scan after {} files due to memory pressure]",
901 batch_no * SCAN_BATCH_FILES
902 );
903 break;
904 }
905 }
906 let results = process_scan_targets(batch, &old_files, existing.as_ref(), parallel);
907 for r in results {
908 if r.reused {
909 reused += 1;
910 } else {
911 scanned += 1;
912 }
913 index.files.insert(r.rel.clone(), r.file_entry);
914 for (key, sym) in r.symbols {
915 index.symbols.insert(key, sym);
916 }
917 content_cache.insert(r.rel, r.content);
918 }
919 }
920
921 build_edges_cached(&mut index, &content_cache);
922
923 if let Err(e) = index.save() {
924 tracing::warn!("could not save graph index: {e}");
925 }
926
927 tracing::debug!(
928 "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
929 index.file_count(),
930 scanned,
931 reused,
932 index.symbol_count(),
933 index.edge_count()
934 );
935
936 (index, content_cache)
937}
938
939type OldFileSymbols = HashMap<String, (String, Vec<(String, SymbolEntry)>)>;
942
943#[derive(Debug, PartialEq)]
946struct ScanFileResult {
947 rel: String,
948 file_entry: FileEntry,
949 symbols: Vec<(String, SymbolEntry)>,
951 content: String,
952 reused: bool,
953}
954
955fn process_scan_file(
959 file_path: &str,
960 rel: &str,
961 ext: &str,
962 old_files: &OldFileSymbols,
963 existing: Option<&ProjectIndex>,
964) -> Option<ScanFileResult> {
965 let content = std::fs::read_to_string(file_path).ok()?;
966 let hash = compute_hash(&content);
967
968 if let Some((old_hash, old_syms)) = old_files.get(rel)
970 && *old_hash == hash
971 && let Some(old_entry) = existing.and_then(|p| p.files.get(rel))
972 {
973 return Some(ScanFileResult {
974 rel: rel.to_string(),
975 file_entry: old_entry.clone(),
976 symbols: old_syms.clone(),
977 content,
978 reused: true,
979 });
980 }
981
982 let sigs = signatures::extract_signatures(&content, ext);
983 let line_count = content.lines().count();
984 let token_count = crate::core::tokens::count_tokens(&content);
985 let summary = extract_summary(&content);
986
987 let exports: Vec<String> = sigs
988 .iter()
989 .filter(|s| s.is_exported)
990 .map(|s| s.name.clone())
991 .collect();
992
993 let file_entry = FileEntry {
994 path: rel.to_string(),
995 hash,
996 language: ext.to_string(),
997 line_count,
998 token_count,
999 exports,
1000 summary,
1001 };
1002
1003 let symbols: Vec<(String, SymbolEntry)> = sigs
1004 .iter()
1005 .map(|sig| {
1006 let (start, end) = sig
1007 .start_line
1008 .zip(sig.end_line)
1009 .unwrap_or_else(|| find_symbol_range(&content, sig));
1010 let key = format!("{rel}::{}", sig.name);
1011 (
1012 key,
1013 SymbolEntry {
1014 file: rel.to_string(),
1015 name: sig.name.clone(),
1016 kind: sig.kind.to_string(),
1017 start_line: start,
1018 end_line: end,
1019 is_exported: sig.is_exported,
1020 },
1021 )
1022 })
1023 .collect();
1024
1025 Some(ScanFileResult {
1026 rel: rel.to_string(),
1027 file_entry,
1028 symbols,
1029 content,
1030 reused: false,
1031 })
1032}
1033
1034fn process_scan_targets(
1039 targets: &[(String, String, String)],
1040 old_files: &OldFileSymbols,
1041 existing: Option<&ProjectIndex>,
1042 parallel: bool,
1043) -> Vec<ScanFileResult> {
1044 if parallel {
1045 targets
1046 .par_iter()
1047 .filter_map(|(file_path, rel, ext)| {
1048 process_scan_file(file_path, rel, ext, old_files, existing)
1049 })
1050 .collect()
1051 } else {
1052 targets
1053 .iter()
1054 .filter_map(|(file_path, rel, ext)| {
1055 process_scan_file(file_path, rel, ext, old_files, existing)
1056 })
1057 .collect()
1058 }
1059}
1060
1061fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
1062 let lines: Vec<&str> = content.lines().collect();
1063 let mut start = 0;
1064
1065 for (i, line) in lines.iter().enumerate() {
1066 if line.contains(&sig.name) {
1067 let trimmed = line.trim();
1068 let is_def = trimmed.starts_with("fn ")
1069 || trimmed.starts_with("pub fn ")
1070 || trimmed.starts_with("pub(crate) fn ")
1071 || trimmed.starts_with("async fn ")
1072 || trimmed.starts_with("pub async fn ")
1073 || trimmed.starts_with("struct ")
1074 || trimmed.starts_with("pub struct ")
1075 || trimmed.starts_with("enum ")
1076 || trimmed.starts_with("pub enum ")
1077 || trimmed.starts_with("trait ")
1078 || trimmed.starts_with("pub trait ")
1079 || trimmed.starts_with("impl ")
1080 || trimmed.starts_with("class ")
1081 || trimmed.starts_with("export class ")
1082 || trimmed.starts_with("export function ")
1083 || trimmed.starts_with("export async function ")
1084 || trimmed.starts_with("function ")
1085 || trimmed.starts_with("async function ")
1086 || trimmed.starts_with("def ")
1087 || trimmed.starts_with("async def ")
1088 || trimmed.starts_with("func ")
1089 || trimmed.starts_with("interface ")
1090 || trimmed.starts_with("export interface ")
1091 || trimmed.starts_with("type ")
1092 || trimmed.starts_with("export type ")
1093 || trimmed.starts_with("const ")
1094 || trimmed.starts_with("export const ")
1095 || trimmed.starts_with("fun ")
1096 || trimmed.starts_with("private fun ")
1097 || trimmed.starts_with("public fun ")
1098 || trimmed.starts_with("internal fun ")
1099 || trimmed.starts_with("class ")
1100 || trimmed.starts_with("data class ")
1101 || trimmed.starts_with("sealed class ")
1102 || trimmed.starts_with("sealed interface ")
1103 || trimmed.starts_with("enum class ")
1104 || trimmed.starts_with("object ")
1105 || trimmed.starts_with("private object ")
1106 || trimmed.starts_with("interface ")
1107 || trimmed.starts_with("typealias ")
1108 || trimmed.starts_with("private typealias ");
1109 if is_def {
1110 start = i + 1;
1111 break;
1112 }
1113 }
1114 }
1115
1116 if start == 0 {
1117 return (1, lines.len().min(20));
1118 }
1119
1120 let base_indent = lines
1121 .get(start - 1)
1122 .map_or(0, |l| l.len() - l.trim_start().len());
1123
1124 let mut end = start;
1125 let mut brace_depth: i32 = 0;
1126 let mut found_open = false;
1127
1128 for (i, line) in lines.iter().enumerate().skip(start - 1) {
1129 for ch in line.chars() {
1130 if ch == '{' {
1131 brace_depth += 1;
1132 found_open = true;
1133 } else if ch == '}' {
1134 brace_depth -= 1;
1135 }
1136 }
1137
1138 end = i + 1;
1139
1140 if found_open && brace_depth <= 0 {
1141 break;
1142 }
1143
1144 if !found_open && i > start {
1145 let indent = line.len() - line.trim_start().len();
1146 if indent <= base_indent && !line.trim().is_empty() && i > start {
1147 end = i;
1148 break;
1149 }
1150 }
1151
1152 if end - start > 200 {
1153 break;
1154 }
1155 }
1156
1157 (start, end)
1158}
1159
1160fn extract_summary(content: &str) -> String {
1161 for line in content.lines().take(20) {
1162 let trimmed = line.trim();
1163 if trimmed.is_empty()
1164 || trimmed.starts_with("//")
1165 || trimmed.starts_with('#')
1166 || trimmed.starts_with("/*")
1167 || trimmed.starts_with('*')
1168 || trimmed.starts_with("use ")
1169 || trimmed.starts_with("import ")
1170 || trimmed.starts_with("from ")
1171 || trimmed.starts_with("require(")
1172 || trimmed.starts_with("package ")
1173 {
1174 continue;
1175 }
1176 return trimmed.chars().take(120).collect();
1177 }
1178 String::new()
1179}
1180
1181fn compute_hash(content: &str) -> String {
1182 use std::collections::hash_map::DefaultHasher;
1183 use std::hash::{Hash, Hasher};
1184
1185 let mut hasher = DefaultHasher::new();
1186 content.hash(&mut hasher);
1187 format!("{:016x}", hasher.finish())
1188}
1189
1190#[cfg(test)]
1191fn short_hash(input: &str) -> String {
1192 use std::collections::hash_map::DefaultHasher;
1193 use std::hash::{Hash, Hasher};
1194
1195 let mut hasher = DefaultHasher::new();
1196 input.hash(&mut hasher);
1197 format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1198}
1199
1200fn make_relative(path: &str, root: &str) -> String {
1201 graph_relative_key(path, root)
1202}
1203
1204fn is_indexable_ext(ext: &str) -> bool {
1205 crate::core::language_capabilities::is_indexable_ext(ext)
1206}
1207
1208#[cfg(test)]
1209fn kotlin_package_name(content: &str) -> Option<String> {
1210 content.lines().map(str::trim).find_map(|line| {
1211 line.strip_prefix("package ")
1212 .map(|rest| rest.trim().trim_end_matches(';').to_string())
1213 })
1214}