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