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