1use std::collections::HashMap;
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::core::import_resolver;
14use crate::core::signatures;
15mod edges;
16pub(crate) use edges::*;
17#[cfg(test)]
18mod tests;
19
20const INDEX_VERSION: u32 = 6;
21
22use crate::core::index_paths::normalize_absolute_path;
25pub use crate::core::index_paths::{graph_match_key, graph_relative_key, normalize_project_root};
26
27pub fn is_safe_scan_root_public(path: &str) -> bool {
28 is_safe_scan_root(path)
29}
30
31fn is_filesystem_root(path: &str) -> bool {
32 let p = Path::new(path);
33 p.parent().is_none() || (cfg!(windows) && p.parent() == Some(Path::new("")))
34}
35
36fn dir_has_project_marker(dir: &Path) -> bool {
42 crate::core::pathutil::has_project_marker(dir)
43}
44
45fn has_marker_in_ancestry(p: &Path, stop: &Path) -> bool {
52 let mut cur = Some(p);
53 while let Some(dir) = cur {
54 if dir == stop {
55 return false;
56 }
57 if dir_has_project_marker(dir) {
58 return true;
59 }
60 cur = dir.parent();
61 }
62 false
63}
64
65fn is_safe_scan_root(path: &str) -> bool {
66 let normalized = normalize_project_root(path);
67 let p = Path::new(&normalized);
68
69 if !crate::core::pathutil::may_probe_path(p) {
74 return false;
75 }
76
77 if normalized == "/" || normalized == "\\" || is_filesystem_root(&normalized) {
78 tracing::warn!("[graph_index: refusing to scan filesystem root]");
79 return false;
80 }
81
82 if normalized == "." || normalized.is_empty() {
83 tracing::warn!("[graph_index: refusing to scan relative/empty root]");
84 return false;
85 }
86
87 if let Some(home) = dirs::home_dir() {
88 let home_norm = normalize_project_root(&home.to_string_lossy());
89 if normalized == home_norm {
90 use std::sync::Once;
91 static HOME_WARN: Once = Once::new();
92 HOME_WARN.call_once(|| {
93 tracing::warn!(
94 "[graph_index: skipping — cannot index home directory {normalized}.\n \
95 Run from inside a project, or set LEAN_CTX_PROJECT_ROOT=/path/to/project]"
96 );
97 });
98 return false;
99 }
100 if crate::core::pathutil::is_tcc_sensitive_home_dir(p) {
104 tracing::warn!(
105 "[graph_index: refusing to scan {normalized} — macOS TCC-protected home dir]"
106 );
107 return false;
108 }
109 let home_path = Path::new(&home_norm);
111 const BLOCKED_HOME_SUBDIRS: &[&str] = &[
112 "Desktop",
113 "Documents",
114 "Downloads",
115 "Pictures",
116 "Music",
117 "Videos",
118 "Movies",
119 "Library",
120 ".local",
121 ".cache",
122 ".config",
123 "snap",
124 "Applications",
125 "OneDrive",
130 "Dropbox",
131 "Google Drive",
132 ];
133 for blocked in BLOCKED_HOME_SUBDIRS {
134 let blocked_path = home_path.join(blocked);
135 let is_inside_blocked = p == blocked_path || p.starts_with(&blocked_path);
136 let has_marker = has_marker_in_ancestry(p, &blocked_path);
141 if is_inside_blocked
142 && !has_marker
143 && !crate::core::pathutil::has_multi_repo_children(p)
144 {
145 tracing::warn!(
146 "[graph_index: refusing to scan {normalized} — \
147 inside home/{blocked} without project markers]"
148 );
149 return false;
150 }
151 }
152
153 if p.parent() == Some(home_path)
156 && !dir_has_project_marker(p)
157 && !crate::core::pathutil::has_multi_repo_children(p)
158 {
159 tracing::warn!(
160 "[graph_index: refusing to scan {normalized} — \
161 direct child of home without project markers]"
162 );
163 return false;
164 }
165 }
166
167 let breadth_markers = [
168 ".git",
169 "Cargo.toml",
170 "package.json",
171 "go.mod",
172 "pyproject.toml",
173 "setup.py",
174 "Makefile",
175 "CMakeLists.txt",
176 "pnpm-workspace.yaml",
177 ".projectile",
178 "BUILD.bazel",
179 "go.work",
180 ];
181
182 if !breadth_markers.iter().any(|m| p.join(m).exists()) && !dir_has_dotnet_project(p) {
183 if crate::core::pathutil::has_multi_repo_children(p) {
185 return true;
186 }
187
188 let child_count = std::fs::read_dir(p).map_or(0, |rd| {
189 rd.filter_map(Result::ok)
190 .filter(|e| e.path().is_dir())
191 .count()
192 });
193 if child_count > 50 {
194 tracing::warn!(
195 "[graph_index: {normalized} has no project markers and {child_count} subdirectories — \
196 skipping scan to avoid indexing broad directories]"
197 );
198 return false;
199 }
200 }
201
202 true
203}
204
205fn dir_has_dotnet_project(dir: &Path) -> bool {
209 std::fs::read_dir(dir).is_ok_and(|rd| {
210 rd.filter_map(Result::ok).any(|e| {
211 e.path()
212 .extension()
213 .and_then(|x| x.to_str())
214 .is_some_and(|x| {
215 matches!(
216 x.to_ascii_lowercase().as_str(),
217 "csproj" | "sln" | "fsproj" | "vbproj"
218 )
219 })
220 })
221 })
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct ProjectIndex {
226 pub version: u32,
227 pub project_root: String,
228 pub last_scan: String,
229 pub files: HashMap<String, FileEntry>,
230 pub edges: Vec<IndexEdge>,
231 pub symbols: HashMap<String, SymbolEntry>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct FileEntry {
236 pub path: String,
237 pub hash: String,
238 pub language: String,
239 pub line_count: usize,
240 pub token_count: usize,
241 pub exports: Vec<String>,
242 pub summary: String,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct SymbolEntry {
247 pub file: String,
248 pub name: String,
249 pub kind: String,
250 pub start_line: usize,
251 pub end_line: usize,
252 pub is_exported: bool,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct IndexEdge {
257 pub from: String,
258 pub to: String,
259 pub kind: String,
260 #[serde(default = "default_edge_weight")]
261 pub weight: f32,
262}
263
264fn default_edge_weight() -> f32 {
265 1.0
266}
267
268impl ProjectIndex {
269 pub fn new(project_root: &str) -> Self {
270 Self {
271 version: INDEX_VERSION,
272 project_root: normalize_project_root(project_root),
273 last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
274 files: HashMap::new(),
275 edges: Vec::new(),
276 symbols: HashMap::new(),
277 }
278 }
279
280 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
281 let normalized = normalize_project_root(project_root);
282 let hash = crate::core::project_hash::hash_project_root(&normalized);
283 crate::core::data_dir::lean_ctx_data_dir()
284 .ok()
285 .map(|d| d.join("graphs").join(hash))
286 }
287
288 pub fn load(project_root: &str) -> Option<Self> {
294 let graph = crate::core::property_graph::CodeGraph::open(project_root).ok()?;
295 if graph.file_catalog_count().unwrap_or(0) == 0 {
296 return None;
297 }
298 let provider = crate::core::graph_provider::GraphProvider::PropertyGraph(graph);
299 Some(provider.materialize_project_index(project_root))
300 }
301
302 pub fn save(&self) -> Result<(), String> {
307 crate::core::property_graph::mirror_index(&self.project_root, self)
308 .map_err(|e| e.to_string())
309 }
310
311 pub fn purge_stale_indices() {
314 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
315 return;
316 };
317 let graphs_dir = data_dir.join("graphs");
318 let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
319 return;
320 };
321 let cfg = crate::core::config::Config::load();
322 let max_age_secs = cfg.archive_max_age_hours_effective() * 3600;
323
324 for entry in entries.filter_map(Result::ok) {
325 let path = entry.path();
326 if !path.is_dir() {
327 continue;
328 }
329 let meta = path.join("graph.meta.json");
333 let db = path.join("graph.db");
334 let index_file = if meta.exists() {
335 &meta
336 } else if db.exists() {
337 &db
338 } else {
339 continue;
340 };
341
342 let is_old = index_file
343 .metadata()
344 .and_then(|m| m.modified())
345 .is_ok_and(|mtime| {
346 mtime
347 .elapsed()
348 .is_ok_and(|age| age.as_secs() > max_age_secs)
349 });
350
351 if is_old {
352 tracing::info!("[graph_index: purging stale index at {}]", path.display());
353 let _ = std::fs::remove_dir_all(&path);
354 }
355 }
356 }
357
358 pub fn file_count(&self) -> usize {
359 self.files.len()
360 }
361
362 pub fn symbol_count(&self) -> usize {
363 self.symbols.len()
364 }
365
366 pub fn edge_count(&self) -> usize {
367 self.edges.len()
368 }
369
370 pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
371 self.symbols.get(key)
372 }
373
374 pub fn get_reverse_deps(&self, path: &str, depth: usize) -> Vec<String> {
375 let mut result = Vec::new();
376 let mut visited = std::collections::HashSet::new();
377 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
378
379 while let Some((current, d)) = queue.pop() {
380 if d > depth || visited.contains(¤t) {
381 continue;
382 }
383 visited.insert(current.clone());
384 if current != path {
385 result.push(current.clone());
386 }
387
388 for edge in &self.edges {
389 if edge.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
390 queue.push((edge.from.clone(), d + 1));
391 }
392 }
393 }
394 result
395 }
396
397 pub fn get_forward_deps(&self, path: &str, depth: usize) -> Vec<String> {
400 let mut result = Vec::new();
401 let mut visited = std::collections::HashSet::new();
402 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
403
404 while let Some((current, d)) = queue.pop() {
405 if d > depth || visited.contains(¤t) {
406 continue;
407 }
408 visited.insert(current.clone());
409 if current != path {
410 result.push(current.clone());
411 }
412
413 for edge in &self.edges {
414 if edge.from == current && edge.kind == "import" && !visited.contains(&edge.to) {
415 queue.push((edge.to.clone(), d + 1));
416 }
417 }
418 }
419 result
420 }
421
422 pub fn get_related(&self, path: &str, depth: usize) -> Vec<String> {
423 let mut result = Vec::new();
424 let mut visited = std::collections::HashSet::new();
425 let mut queue: Vec<(String, usize)> = vec![(path.to_string(), 0)];
426
427 while let Some((current, d)) = queue.pop() {
428 if d > depth || visited.contains(¤t) {
429 continue;
430 }
431 visited.insert(current.clone());
432 if current != path {
433 result.push(current.clone());
434 }
435
436 for edge in &self.edges {
437 if edge.from == current && !visited.contains(&edge.to) {
438 queue.push((edge.to.clone(), d + 1));
439 }
440 if edge.to == current && !visited.contains(&edge.from) {
441 queue.push((edge.from.clone(), d + 1));
442 }
443 }
444 }
445 result
446 }
447}
448
449pub fn load_or_build(project_root: &str) -> ProjectIndex {
453 if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
454 return ProjectIndex::load(project_root).unwrap_or_else(|| ProjectIndex::new(project_root));
455 }
456
457 let root_abs = if project_root.trim().is_empty() || project_root == "." {
460 std::env::current_dir().ok().map_or_else(
461 || ".".to_string(),
462 |p| normalize_project_root(&p.to_string_lossy()),
463 )
464 } else {
465 normalize_project_root(project_root)
466 };
467
468 if !is_safe_scan_root(&root_abs) {
469 return ProjectIndex::new(&root_abs);
470 }
471
472 if let Some(idx) = ProjectIndex::load(&root_abs)
474 && !idx.files.is_empty()
475 {
476 if index_looks_stale(&idx, &root_abs) {
477 tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
478 return scan(&root_abs);
479 }
480 return idx;
481 }
482
483 if let Ok(cwd) = std::env::current_dir() {
485 let cwd_str = normalize_project_root(&cwd.to_string_lossy());
486 if cwd_str != root_abs
487 && cwd_str.starts_with(&root_abs)
488 && let Some(idx) = ProjectIndex::load(&cwd_str)
489 && !idx.files.is_empty()
490 {
491 if index_looks_stale(&idx, &cwd_str) {
492 return scan(&cwd_str);
493 }
494 return idx;
495 }
496 }
497
498 scan(&root_abs)
499}
500
501fn index_looks_stale(index: &ProjectIndex, root_abs: &str) -> bool {
502 if index.files.is_empty() {
503 return true;
504 }
505
506 if let Ok(scan_time) =
508 chrono::NaiveDateTime::parse_from_str(&index.last_scan, "%Y-%m-%d %H:%M:%S")
509 {
510 let cfg = crate::core::config::Config::load();
511 let effective_hours = cfg.archive_max_age_hours_effective();
512 let max_age = chrono::Duration::hours(effective_hours as i64);
513 let now = chrono::Local::now().naive_local();
514 if now.signed_duration_since(scan_time) > max_age {
515 tracing::info!(
516 "[graph_index: index is older than {}h — marking stale]",
517 effective_hours
518 );
519 return true;
520 }
521 }
522
523 const CONTAMINATION_MARKERS: &[&str] = &[
526 "Desktop/",
527 "Documents/",
528 "Downloads/",
529 "Pictures/",
530 "Music/",
531 "Videos/",
532 "Movies/",
533 "Library/",
534 ".cache/",
535 "snap/",
536 ];
537 let contaminated = index.files.keys().take(200).any(|rel| {
538 CONTAMINATION_MARKERS
539 .iter()
540 .any(|m| rel.starts_with(m) || rel.contains(&format!("/{m}")))
541 });
542 if contaminated {
543 tracing::warn!(
544 "[graph_index: index contains files from user directories (Desktop/Documents/...) — \
545 marking stale to force clean rebuild]"
546 );
547 return true;
548 }
549
550 let root_path = Path::new(root_abs);
551 let sample_size = index.files.len().min(20);
553 for rel in index.files.keys().take(sample_size) {
554 let rel = rel.trim_start_matches(['/', '\\']);
555 if rel.is_empty() {
556 continue;
557 }
558 let abs = root_path.join(rel);
559 if !abs.exists() {
560 return true;
561 }
562 }
563
564 if source_content_changed_since_index(index, root_abs) {
569 tracing::info!("[graph_index: source content changed since last scan — marking stale]");
570 return true;
571 }
572
573 false
574}
575
576fn index_file_mtime(root_abs: &str) -> Option<std::time::SystemTime> {
582 let dir = ProjectIndex::index_dir(root_abs)?;
583 for name in ["graph.meta.json", "graph.db"] {
584 if let Ok(meta) = std::fs::metadata(dir.join(name))
585 && let Ok(modified) = meta.modified()
586 {
587 return Some(modified);
588 }
589 }
590 None
591}
592
593fn source_content_changed_since_index(index: &ProjectIndex, root_abs: &str) -> bool {
606 let Some(index_mtime) = index_file_mtime(root_abs) else {
607 return false;
609 };
610 let walker = ignore::WalkBuilder::new(root_abs)
611 .hidden(true)
612 .git_ignore(true)
613 .git_global(true)
614 .git_exclude(true)
615 .require_git(false)
616 .max_depth(Some(20))
617 .filter_entry(crate::core::walk_filter::keep_entry)
618 .build();
619 const MAX_VISIT: usize = 50_000;
620 const MAX_CONFIRM_READS: usize = 4_000;
621 let mut visited = 0usize;
622 let mut confirm_reads = 0usize;
623 for entry in walker.filter_map(std::result::Result::ok) {
624 visited += 1;
625 if visited > MAX_VISIT {
626 break;
627 }
628 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
629 continue;
630 }
631 let path = entry.path();
632 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
633 if !is_indexable_ext(ext) {
634 continue;
635 }
636 let Ok(meta) = entry.metadata() else { continue };
638 let Ok(modified) = meta.modified() else {
639 continue;
640 };
641 if modified <= index_mtime {
642 continue;
643 }
644 let rel = make_relative(&path.to_string_lossy(), root_abs);
646 let Some(file_entry) = index.files.get(&rel) else {
647 return true;
649 };
650 confirm_reads += 1;
651 if confirm_reads > MAX_CONFIRM_READS {
652 return true;
654 }
655 match std::fs::read_to_string(path) {
656 Ok(content) if compute_hash(&content) == file_entry.hash => {}
658 _ => return true,
660 }
661 }
662 false
663}
664
665pub fn purge_index(project_root: &str) {
671 if let Some(dir) = ProjectIndex::index_dir(project_root) {
672 for name in [
673 "graph.db",
674 "graph.db-wal",
675 "graph.db-shm",
676 "graph.meta.json",
677 "index.json.zst",
678 "index.json",
679 "call_graph.json.zst",
680 ] {
681 let _ = std::fs::remove_file(dir.join(name));
682 }
683 }
684}
685
686pub fn scan(project_root: &str) -> ProjectIndex {
687 scan_inner(project_root).0
688}
689
690pub fn scan_with_content_cache(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
691 scan_inner(project_root)
692}
693
694fn scan_inner(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
695 if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
696 tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
697 return (ProjectIndex::new(project_root), HashMap::new());
698 }
699
700 let project_root = normalize_project_root(project_root);
701
702 if !is_safe_scan_root(&project_root) {
703 tracing::warn!("[graph_index: scan aborted for unsafe root {project_root}]");
704 return (ProjectIndex::new(&project_root), HashMap::new());
705 }
706
707 let lock_name = format!(
708 "graph-idx-{}",
709 &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
710 );
711 let _lock = crate::core::startup_guard::try_acquire_lock(
712 &lock_name,
713 std::time::Duration::from_millis(800),
714 std::time::Duration::from_mins(3),
715 );
716 if _lock.is_none() {
717 tracing::info!(
718 "[graph_index: another process is scanning {project_root} — returning cached or empty]"
719 );
720 return (
721 ProjectIndex::load(&project_root).unwrap_or_else(|| ProjectIndex::new(&project_root)),
722 HashMap::new(),
723 );
724 }
725
726 let existing = ProjectIndex::load(&project_root);
727 let mut index = ProjectIndex::new(&project_root);
728
729 let old_files: HashMap<String, (String, Vec<(String, SymbolEntry)>)> =
730 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 for entry in walker.filter_map(std::result::Result::ok) {
778 entries_visited += 1;
779 if entries_visited > MAX_ENTRIES_VISITED {
780 tracing::warn!(
781 "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
782 runaway traversal. Indexed {} files so far.]",
783 index.files.len()
784 );
785 break;
786 }
787 if entries_visited.is_multiple_of(5000) {
788 if std::time::Instant::now() > scan_deadline {
789 tracing::warn!(
790 "[graph_index: scan timeout (120s) after {entries_visited} entries — \
791 saving partial index with {} files]",
792 index.files.len()
793 );
794 break;
795 }
796 if crate::core::memory_guard::abort_requested() {
797 tracing::warn!(
798 "[graph_index: memory pressure abort after {entries_visited} entries — \
799 saving partial index with {} files]",
800 index.files.len()
801 );
802 break;
803 }
804 if crate::core::memory_guard::is_under_pressure() {
805 tracing::warn!(
806 "[graph_index: memory pressure detected at {entries_visited} entries — \
807 stopping scan with {} files]",
808 index.files.len()
809 );
810 break;
811 }
812 if let Some(ref g) = _lock {
813 g.touch();
814 }
815 }
816
817 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
818 continue;
819 }
820
821 if entry.path_is_symlink() {
822 continue;
823 }
824 let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
825
826 if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
827 continue;
828 }
829
830 if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
831 if meta.file_type().is_symlink() || !meta.is_file() {
832 continue;
833 }
834 if meta.len() > MAX_FILE_SIZE_BYTES {
835 tracing::debug!(
836 "[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
837 meta.len() as f64 / 1_048_576.0,
838 MAX_FILE_SIZE_BYTES / (1024 * 1024),
839 );
840 continue;
841 }
842 }
843
844 let ext = Path::new(&file_path)
845 .extension()
846 .and_then(|e| e.to_str())
847 .unwrap_or("");
848
849 if !is_indexable_ext(ext) {
850 continue;
851 }
852
853 let rel = make_relative(&file_path, &project_root);
854 if extra_ignores.iter().any(|p| p.matches(&rel)) {
855 continue;
856 }
857
858 if max_files != usize::MAX && index.files.len() >= max_files {
859 tracing::info!(
860 "[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
861 max_files
862 );
863 break;
864 }
865
866 let Ok(content) = std::fs::read_to_string(&file_path) else {
867 continue;
868 };
869
870 let hash = compute_hash(&content);
871 let rel_path = make_relative(&file_path, &project_root);
872
873 if let Some((old_hash, old_syms)) = old_files.get(&rel_path)
874 && *old_hash == hash
875 && let Some(old_entry) = existing.as_ref().and_then(|p| p.files.get(&rel_path))
876 {
877 index.files.insert(rel_path.clone(), old_entry.clone());
878 for (key, sym) in old_syms {
879 index.symbols.insert(key.clone(), sym.clone());
880 }
881 content_cache.insert(rel_path, content);
882 reused += 1;
883 continue;
884 }
885
886 let sigs = signatures::extract_signatures(&content, ext);
887 let line_count = content.lines().count();
888 let token_count = crate::core::tokens::count_tokens(&content);
889 let summary = extract_summary(&content);
890
891 let exports: Vec<String> = sigs
892 .iter()
893 .filter(|s| s.is_exported)
894 .map(|s| s.name.clone())
895 .collect();
896
897 index.files.insert(
898 rel_path.clone(),
899 FileEntry {
900 path: rel_path.clone(),
901 hash,
902 language: ext.to_string(),
903 line_count,
904 token_count,
905 exports,
906 summary,
907 },
908 );
909
910 for sig in &sigs {
911 let (start, end) = sig
912 .start_line
913 .zip(sig.end_line)
914 .unwrap_or_else(|| find_symbol_range(&content, sig));
915 let key = format!("{}::{}", rel_path, sig.name);
916 index.symbols.insert(
917 key,
918 SymbolEntry {
919 file: rel_path.clone(),
920 name: sig.name.clone(),
921 kind: sig.kind.to_string(),
922 start_line: start,
923 end_line: end,
924 is_exported: sig.is_exported,
925 },
926 );
927 }
928
929 content_cache.insert(rel_path, content);
930 scanned += 1;
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
951fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
952 let lines: Vec<&str> = content.lines().collect();
953 let mut start = 0;
954
955 for (i, line) in lines.iter().enumerate() {
956 if line.contains(&sig.name) {
957 let trimmed = line.trim();
958 let is_def = trimmed.starts_with("fn ")
959 || trimmed.starts_with("pub fn ")
960 || trimmed.starts_with("pub(crate) fn ")
961 || trimmed.starts_with("async fn ")
962 || trimmed.starts_with("pub async fn ")
963 || trimmed.starts_with("struct ")
964 || trimmed.starts_with("pub struct ")
965 || trimmed.starts_with("enum ")
966 || trimmed.starts_with("pub enum ")
967 || trimmed.starts_with("trait ")
968 || trimmed.starts_with("pub trait ")
969 || trimmed.starts_with("impl ")
970 || trimmed.starts_with("class ")
971 || trimmed.starts_with("export class ")
972 || trimmed.starts_with("export function ")
973 || trimmed.starts_with("export async function ")
974 || trimmed.starts_with("function ")
975 || trimmed.starts_with("async function ")
976 || trimmed.starts_with("def ")
977 || trimmed.starts_with("async def ")
978 || trimmed.starts_with("func ")
979 || trimmed.starts_with("interface ")
980 || trimmed.starts_with("export interface ")
981 || trimmed.starts_with("type ")
982 || trimmed.starts_with("export type ")
983 || trimmed.starts_with("const ")
984 || trimmed.starts_with("export const ")
985 || trimmed.starts_with("fun ")
986 || trimmed.starts_with("private fun ")
987 || trimmed.starts_with("public fun ")
988 || trimmed.starts_with("internal fun ")
989 || trimmed.starts_with("class ")
990 || trimmed.starts_with("data class ")
991 || trimmed.starts_with("sealed class ")
992 || trimmed.starts_with("sealed interface ")
993 || trimmed.starts_with("enum class ")
994 || trimmed.starts_with("object ")
995 || trimmed.starts_with("private object ")
996 || trimmed.starts_with("interface ")
997 || trimmed.starts_with("typealias ")
998 || trimmed.starts_with("private typealias ");
999 if is_def {
1000 start = i + 1;
1001 break;
1002 }
1003 }
1004 }
1005
1006 if start == 0 {
1007 return (1, lines.len().min(20));
1008 }
1009
1010 let base_indent = lines
1011 .get(start - 1)
1012 .map_or(0, |l| l.len() - l.trim_start().len());
1013
1014 let mut end = start;
1015 let mut brace_depth: i32 = 0;
1016 let mut found_open = false;
1017
1018 for (i, line) in lines.iter().enumerate().skip(start - 1) {
1019 for ch in line.chars() {
1020 if ch == '{' {
1021 brace_depth += 1;
1022 found_open = true;
1023 } else if ch == '}' {
1024 brace_depth -= 1;
1025 }
1026 }
1027
1028 end = i + 1;
1029
1030 if found_open && brace_depth <= 0 {
1031 break;
1032 }
1033
1034 if !found_open && i > start {
1035 let indent = line.len() - line.trim_start().len();
1036 if indent <= base_indent && !line.trim().is_empty() && i > start {
1037 end = i;
1038 break;
1039 }
1040 }
1041
1042 if end - start > 200 {
1043 break;
1044 }
1045 }
1046
1047 (start, end)
1048}
1049
1050fn extract_summary(content: &str) -> String {
1051 for line in content.lines().take(20) {
1052 let trimmed = line.trim();
1053 if trimmed.is_empty()
1054 || trimmed.starts_with("//")
1055 || trimmed.starts_with('#')
1056 || trimmed.starts_with("/*")
1057 || trimmed.starts_with('*')
1058 || trimmed.starts_with("use ")
1059 || trimmed.starts_with("import ")
1060 || trimmed.starts_with("from ")
1061 || trimmed.starts_with("require(")
1062 || trimmed.starts_with("package ")
1063 {
1064 continue;
1065 }
1066 return trimmed.chars().take(120).collect();
1067 }
1068 String::new()
1069}
1070
1071fn compute_hash(content: &str) -> String {
1072 use std::collections::hash_map::DefaultHasher;
1073 use std::hash::{Hash, Hasher};
1074
1075 let mut hasher = DefaultHasher::new();
1076 content.hash(&mut hasher);
1077 format!("{:016x}", hasher.finish())
1078}
1079
1080#[cfg(test)]
1081fn short_hash(input: &str) -> String {
1082 use std::collections::hash_map::DefaultHasher;
1083 use std::hash::{Hash, Hasher};
1084
1085 let mut hasher = DefaultHasher::new();
1086 input.hash(&mut hasher);
1087 format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1088}
1089
1090fn make_relative(path: &str, root: &str) -> String {
1091 graph_relative_key(path, root)
1092}
1093
1094fn is_indexable_ext(ext: &str) -> bool {
1095 crate::core::language_capabilities::is_indexable_ext(ext)
1096}
1097
1098#[cfg(test)]
1099fn kotlin_package_name(content: &str) -> Option<String> {
1100 content.lines().map(str::trim).find_map(|line| {
1101 line.strip_prefix("package ")
1102 .map(|rest| rest.trim().trim_end_matches(';').to_string())
1103 })
1104}