1use std::collections::HashMap;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11
12use crate::core::import_resolver;
13use crate::core::signatures;
14mod edges;
15pub(crate) use edges::*;
16#[cfg(test)]
17mod tests;
18
19const INDEX_VERSION: u32 = 6;
20
21pub fn is_safe_scan_root_public(path: &str) -> bool {
22 is_safe_scan_root(path)
23}
24
25fn is_filesystem_root(path: &str) -> bool {
26 let p = Path::new(path);
27 p.parent().is_none() || (cfg!(windows) && p.parent() == Some(Path::new("")))
28}
29
30const PROJECT_MARKERS: &[&str] = &[
32 ".git",
33 "Cargo.toml",
34 "package.json",
35 "go.mod",
36 "pyproject.toml",
37];
38
39fn dir_has_project_marker(dir: &Path) -> bool {
40 PROJECT_MARKERS.iter().any(|m| dir.join(m).exists())
41}
42
43fn has_marker_in_ancestry(p: &Path, stop: &Path) -> bool {
50 let mut cur = Some(p);
51 while let Some(dir) = cur {
52 if dir == stop {
53 return false;
54 }
55 if dir_has_project_marker(dir) {
56 return true;
57 }
58 cur = dir.parent();
59 }
60 false
61}
62
63fn is_safe_scan_root(path: &str) -> bool {
64 let normalized = normalize_project_root(path);
65 let p = Path::new(&normalized);
66
67 if normalized == "/" || normalized == "\\" || is_filesystem_root(&normalized) {
68 tracing::warn!("[graph_index: refusing to scan filesystem root]");
69 return false;
70 }
71
72 if normalized == "." || normalized.is_empty() {
73 tracing::warn!("[graph_index: refusing to scan relative/empty root]");
74 return false;
75 }
76
77 if let Some(home) = dirs::home_dir() {
78 let home_norm = normalize_project_root(&home.to_string_lossy());
79 if normalized == home_norm {
80 use std::sync::Once;
81 static HOME_WARN: Once = Once::new();
82 HOME_WARN.call_once(|| {
83 tracing::warn!(
84 "[graph_index: skipping — cannot index home directory {normalized}.\n \
85 Run from inside a project, or set LEAN_CTX_PROJECT_ROOT=/path/to/project]"
86 );
87 });
88 return false;
89 }
90 if crate::core::pathutil::is_tcc_sensitive_home_dir(p) {
94 tracing::warn!(
95 "[graph_index: refusing to scan {normalized} — macOS TCC-protected home dir]"
96 );
97 return false;
98 }
99 let home_path = Path::new(&home_norm);
101 const BLOCKED_HOME_SUBDIRS: &[&str] = &[
102 "Desktop",
103 "Documents",
104 "Downloads",
105 "Pictures",
106 "Music",
107 "Videos",
108 "Movies",
109 "Library",
110 ".local",
111 ".cache",
112 ".config",
113 "snap",
114 "Applications",
115 "OneDrive",
120 "Dropbox",
121 "Google Drive",
122 ];
123 for blocked in BLOCKED_HOME_SUBDIRS {
124 let blocked_path = home_path.join(blocked);
125 let is_inside_blocked = p == blocked_path || p.starts_with(&blocked_path);
126 let has_marker = has_marker_in_ancestry(p, &blocked_path);
131 if is_inside_blocked
132 && !has_marker
133 && !crate::core::pathutil::has_multi_repo_children(p)
134 {
135 tracing::warn!(
136 "[graph_index: refusing to scan {normalized} — \
137 inside home/{blocked} without project markers]"
138 );
139 return false;
140 }
141 }
142
143 if p.parent() == Some(home_path)
146 && !dir_has_project_marker(p)
147 && !crate::core::pathutil::has_multi_repo_children(p)
148 {
149 tracing::warn!(
150 "[graph_index: refusing to scan {normalized} — \
151 direct child of home without project markers]"
152 );
153 return false;
154 }
155 }
156
157 let breadth_markers = [
158 ".git",
159 "Cargo.toml",
160 "package.json",
161 "go.mod",
162 "pyproject.toml",
163 "setup.py",
164 "Makefile",
165 "CMakeLists.txt",
166 "pnpm-workspace.yaml",
167 ".projectile",
168 "BUILD.bazel",
169 "go.work",
170 ];
171
172 if !breadth_markers.iter().any(|m| p.join(m).exists()) && !dir_has_dotnet_project(p) {
173 if crate::core::pathutil::has_multi_repo_children(p) {
175 return true;
176 }
177
178 let child_count = std::fs::read_dir(p).map_or(0, |rd| {
179 rd.filter_map(Result::ok)
180 .filter(|e| e.path().is_dir())
181 .count()
182 });
183 if child_count > 50 {
184 tracing::warn!(
185 "[graph_index: {normalized} has no project markers and {child_count} subdirectories — \
186 skipping scan to avoid indexing broad directories]"
187 );
188 return false;
189 }
190 }
191
192 true
193}
194
195fn dir_has_dotnet_project(dir: &Path) -> bool {
199 std::fs::read_dir(dir).is_ok_and(|rd| {
200 rd.filter_map(Result::ok).any(|e| {
201 e.path()
202 .extension()
203 .and_then(|x| x.to_str())
204 .is_some_and(|x| {
205 matches!(
206 x.to_ascii_lowercase().as_str(),
207 "csproj" | "sln" | "fsproj" | "vbproj"
208 )
209 })
210 })
211 })
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ProjectIndex {
216 pub version: u32,
217 pub project_root: String,
218 pub last_scan: String,
219 pub files: HashMap<String, FileEntry>,
220 pub edges: Vec<IndexEdge>,
221 pub symbols: HashMap<String, SymbolEntry>,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct FileEntry {
226 pub path: String,
227 pub hash: String,
228 pub language: String,
229 pub line_count: usize,
230 pub token_count: usize,
231 pub exports: Vec<String>,
232 pub summary: String,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct SymbolEntry {
237 pub file: String,
238 pub name: String,
239 pub kind: String,
240 pub start_line: usize,
241 pub end_line: usize,
242 pub is_exported: bool,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct IndexEdge {
247 pub from: String,
248 pub to: String,
249 pub kind: String,
250 #[serde(default = "default_edge_weight")]
251 pub weight: f32,
252}
253
254fn default_edge_weight() -> f32 {
255 1.0
256}
257
258impl ProjectIndex {
259 pub fn new(project_root: &str) -> Self {
260 Self {
261 version: INDEX_VERSION,
262 project_root: normalize_project_root(project_root),
263 last_scan: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
264 files: HashMap::new(),
265 edges: Vec::new(),
266 symbols: HashMap::new(),
267 }
268 }
269
270 pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
271 let normalized = normalize_project_root(project_root);
272 let hash = crate::core::project_hash::hash_project_root(&normalized);
273 crate::core::data_dir::lean_ctx_data_dir()
274 .ok()
275 .map(|d| d.join("graphs").join(hash))
276 }
277
278 pub fn load(project_root: &str) -> Option<Self> {
279 let dir = Self::index_dir(project_root)?;
280
281 let zst_path = dir.join("index.json.zst");
282 if zst_path.exists() {
283 let compressed = std::fs::read(&zst_path).ok()?;
284 let data = zstd::decode_all(compressed.as_slice()).ok()?;
285 let content = String::from_utf8(data).ok()?;
286 let index: Self = serde_json::from_str(&content).ok()?;
287 if index.version != INDEX_VERSION {
288 return None;
289 }
290 return Some(index);
291 }
292
293 let json_path = dir.join("index.json");
294 let content = std::fs::read_to_string(&json_path)
295 .or_else(|_| -> std::io::Result<String> {
296 let legacy_hash = short_hash(&normalize_project_root(project_root));
297 let legacy_dir = crate::core::data_dir::lean_ctx_data_dir()
298 .map_err(|_| std::io::Error::new(std::io::ErrorKind::NotFound, "no data dir"))?
299 .join("graphs")
300 .join(legacy_hash);
301 let legacy_path = legacy_dir.join("index.json");
302 let data = std::fs::read_to_string(&legacy_path)?;
303 if let Err(e) = copy_dir_fallible(&legacy_dir, &dir) {
304 tracing::debug!("graph index migration: {e}");
305 }
306 Ok(data)
307 })
308 .ok()?;
309 let index: Self = serde_json::from_str(&content).ok()?;
310 if index.version != INDEX_VERSION {
311 return None;
312 }
313 if let Ok(compressed) = zstd::encode_all(content.as_bytes(), 9) {
315 let zst_tmp = zst_path.with_extension("zst.tmp");
316 if std::fs::write(&zst_tmp, &compressed).is_ok()
317 && std::fs::rename(&zst_tmp, &zst_path).is_ok()
318 {
319 let _ = std::fs::remove_file(&json_path);
320 }
321 }
322 Some(index)
323 }
324
325 pub fn save(&self) -> Result<(), String> {
326 let dir = Self::index_dir(&self.project_root)
327 .ok_or_else(|| "Cannot determine data directory".to_string())?;
328 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
329 let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
330 let compressed = zstd::encode_all(json.as_bytes(), 9).map_err(|e| format!("zstd: {e}"))?;
331 let target = dir.join("index.json.zst");
332 let tmp = target.with_extension("zst.tmp");
333 std::fs::write(&tmp, &compressed).map_err(|e| e.to_string())?;
334 std::fs::rename(&tmp, &target).map_err(|e| e.to_string())?;
335 let _ = std::fs::remove_file(dir.join("index.json"));
336 Ok(())
337 }
338
339 pub fn purge_stale_indices() {
342 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
343 return;
344 };
345 let graphs_dir = data_dir.join("graphs");
346 let Ok(entries) = std::fs::read_dir(&graphs_dir) else {
347 return;
348 };
349 let cfg = crate::core::config::Config::load();
350 let max_age_secs = cfg.archive_max_age_hours_effective() * 3600;
351
352 for entry in entries.filter_map(Result::ok) {
353 let path = entry.path();
354 if !path.is_dir() {
355 continue;
356 }
357 let zst = path.join("index.json.zst");
358 let json = path.join("index.json");
359 let index_file = if zst.exists() {
360 &zst
361 } else if json.exists() {
362 &json
363 } else {
364 continue;
365 };
366
367 let is_old = index_file
368 .metadata()
369 .and_then(|m| m.modified())
370 .is_ok_and(|mtime| {
371 mtime
372 .elapsed()
373 .is_ok_and(|age| age.as_secs() > max_age_secs)
374 });
375
376 if is_old {
377 tracing::info!("[graph_index: purging stale index at {}]", path.display());
378 let _ = std::fs::remove_dir_all(&path);
379 }
380 }
381 }
382
383 pub fn file_count(&self) -> usize {
384 self.files.len()
385 }
386
387 pub fn symbol_count(&self) -> usize {
388 self.symbols.len()
389 }
390
391 pub fn edge_count(&self) -> usize {
392 self.edges.len()
393 }
394
395 pub fn get_symbol(&self, key: &str) -> Option<&SymbolEntry> {
396 self.symbols.get(key)
397 }
398
399 pub fn get_reverse_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.to == current && edge.kind == "import" && !visited.contains(&edge.from) {
415 queue.push((edge.from.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 if !idx.files.is_empty() {
475 if index_looks_stale(&idx, &root_abs) {
476 tracing::warn!("[graph_index: stale index detected for {root_abs}; rebuilding]");
477 return scan(&root_abs);
478 }
479 return idx;
480 }
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 && cwd_str.starts_with(&root_abs) {
487 if let Some(idx) = ProjectIndex::load(&cwd_str) {
488 if !idx.files.is_empty() {
489 if index_looks_stale(&idx, &cwd_str) {
490 return scan(&cwd_str);
491 }
492 return idx;
493 }
494 }
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> {
578 let dir = ProjectIndex::index_dir(root_abs)?;
579 for name in ["index.json.zst", "index.json"] {
580 if let Ok(meta) = std::fs::metadata(dir.join(name)) {
581 if let Ok(modified) = meta.modified() {
582 return Some(modified);
583 }
584 }
585 }
586 None
587}
588
589fn source_content_changed_since_index(index: &ProjectIndex, root_abs: &str) -> bool {
602 let Some(index_mtime) = index_file_mtime(root_abs) else {
603 return false;
605 };
606 let walker = ignore::WalkBuilder::new(root_abs)
607 .hidden(true)
608 .git_ignore(true)
609 .git_global(true)
610 .git_exclude(true)
611 .require_git(false)
612 .max_depth(Some(20))
613 .filter_entry(crate::core::walk_filter::keep_entry)
614 .build();
615 const MAX_VISIT: usize = 50_000;
616 const MAX_CONFIRM_READS: usize = 4_000;
617 let mut visited = 0usize;
618 let mut confirm_reads = 0usize;
619 for entry in walker.filter_map(std::result::Result::ok) {
620 visited += 1;
621 if visited > MAX_VISIT {
622 break;
623 }
624 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
625 continue;
626 }
627 let path = entry.path();
628 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
629 if !is_indexable_ext(ext) {
630 continue;
631 }
632 let Ok(meta) = entry.metadata() else { continue };
634 let Ok(modified) = meta.modified() else {
635 continue;
636 };
637 if modified <= index_mtime {
638 continue;
639 }
640 let rel = make_relative(&path.to_string_lossy(), root_abs);
642 let Some(file_entry) = index.files.get(&rel) else {
643 return true;
645 };
646 confirm_reads += 1;
647 if confirm_reads > MAX_CONFIRM_READS {
648 return true;
650 }
651 match std::fs::read_to_string(path) {
652 Ok(content) if compute_hash(&content) == file_entry.hash => {}
654 _ => return true,
656 }
657 }
658 false
659}
660
661pub fn purge_index(project_root: &str) {
664 if let Some(dir) = ProjectIndex::index_dir(project_root) {
665 for name in ["index.json.zst", "index.json", "call_graph.json.zst"] {
666 let _ = std::fs::remove_file(dir.join(name));
667 }
668 }
669}
670
671pub fn scan(project_root: &str) -> ProjectIndex {
672 scan_inner(project_root).0
673}
674
675pub fn scan_with_content_cache(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
676 scan_inner(project_root)
677}
678
679fn scan_inner(project_root: &str) -> (ProjectIndex, HashMap<String, String>) {
680 if std::env::var("LEAN_CTX_NO_INDEX").is_ok() {
681 tracing::info!("[graph_index: LEAN_CTX_NO_INDEX set — skipping scan]");
682 return (ProjectIndex::new(project_root), HashMap::new());
683 }
684
685 let project_root = normalize_project_root(project_root);
686
687 if !is_safe_scan_root(&project_root) {
688 tracing::warn!("[graph_index: scan aborted for unsafe root {project_root}]");
689 return (ProjectIndex::new(&project_root), HashMap::new());
690 }
691
692 let lock_name = format!(
693 "graph-idx-{}",
694 &crate::core::index_namespace::namespace_hash(Path::new(&project_root))[..8]
695 );
696 let _lock = crate::core::startup_guard::try_acquire_lock(
697 &lock_name,
698 std::time::Duration::from_millis(800),
699 std::time::Duration::from_mins(3),
700 );
701 if _lock.is_none() {
702 tracing::info!(
703 "[graph_index: another process is scanning {project_root} — returning cached or empty]"
704 );
705 return (
706 ProjectIndex::load(&project_root).unwrap_or_else(|| ProjectIndex::new(&project_root)),
707 HashMap::new(),
708 );
709 }
710
711 let existing = ProjectIndex::load(&project_root);
712 let mut index = ProjectIndex::new(&project_root);
713
714 let old_files: HashMap<String, (String, Vec<(String, SymbolEntry)>)> =
715 if let Some(ref prev) = existing {
716 prev.files
717 .iter()
718 .map(|(path, entry)| {
719 let syms: Vec<(String, SymbolEntry)> = prev
720 .symbols
721 .iter()
722 .filter(|(_, s)| s.file == *path)
723 .map(|(k, v)| (k.clone(), v.clone()))
724 .collect();
725 (path.clone(), (entry.hash.clone(), syms))
726 })
727 .collect()
728 } else {
729 HashMap::new()
730 };
731
732 let walker = ignore::WalkBuilder::new(&project_root)
733 .hidden(true)
734 .git_ignore(true)
735 .git_global(true)
736 .git_exclude(true)
737 .require_git(false)
738 .max_depth(Some(20))
739 .filter_entry(crate::core::walk_filter::keep_entry)
740 .build();
741
742 let cfg = crate::core::config::Config::load();
743 let extra_ignores: Vec<glob::Pattern> = cfg
744 .extra_ignore_patterns
745 .iter()
746 .filter_map(|p| glob::Pattern::new(p).ok())
747 .collect();
748
749 let mut scanned = 0usize;
750 let mut reused = 0usize;
751 let mut entries_visited = 0usize;
752 let mut content_cache: HashMap<String, String> = HashMap::new();
753 let max_files = if cfg.graph_index_max_files == 0 {
754 usize::MAX } else {
756 cfg.graph_index_max_files as usize
757 };
758 const MAX_ENTRIES_VISITED: usize = 500_000;
759 const MAX_FILE_SIZE_BYTES: u64 = 2 * 1024 * 1024; let scan_deadline = std::time::Instant::now() + std::time::Duration::from_mins(5);
761
762 for entry in walker.filter_map(std::result::Result::ok) {
763 entries_visited += 1;
764 if entries_visited > MAX_ENTRIES_VISITED {
765 tracing::warn!(
766 "[graph_index: walked {entries_visited} entries — aborting scan to prevent \
767 runaway traversal. Indexed {} files so far.]",
768 index.files.len()
769 );
770 break;
771 }
772 if entries_visited.is_multiple_of(5000) {
773 if std::time::Instant::now() > scan_deadline {
774 tracing::warn!(
775 "[graph_index: scan timeout (120s) after {entries_visited} entries — \
776 saving partial index with {} files]",
777 index.files.len()
778 );
779 break;
780 }
781 if crate::core::memory_guard::abort_requested() {
782 tracing::warn!(
783 "[graph_index: memory pressure abort after {entries_visited} entries — \
784 saving partial index with {} files]",
785 index.files.len()
786 );
787 break;
788 }
789 if crate::core::memory_guard::is_under_pressure() {
790 tracing::warn!(
791 "[graph_index: memory pressure detected at {entries_visited} entries — \
792 stopping scan with {} files]",
793 index.files.len()
794 );
795 break;
796 }
797 if let Some(ref g) = _lock {
798 g.touch();
799 }
800 }
801
802 if !entry.file_type().is_some_and(|ft| ft.is_file()) {
803 continue;
804 }
805
806 if entry.path_is_symlink() {
807 continue;
808 }
809 let file_path = normalize_absolute_path(&entry.path().to_string_lossy());
810
811 if !std::path::Path::new(&file_path).starts_with(std::path::Path::new(&project_root)) {
812 continue;
813 }
814
815 if let Ok(meta) = std::fs::symlink_metadata(&file_path) {
816 if meta.file_type().is_symlink() || !meta.is_file() {
817 continue;
818 }
819 if meta.len() > MAX_FILE_SIZE_BYTES {
820 tracing::debug!(
821 "[graph_index: skipping {file_path} — {:.1}MB exceeds {}MB limit]",
822 meta.len() as f64 / 1_048_576.0,
823 MAX_FILE_SIZE_BYTES / (1024 * 1024),
824 );
825 continue;
826 }
827 }
828
829 let ext = Path::new(&file_path)
830 .extension()
831 .and_then(|e| e.to_str())
832 .unwrap_or("");
833
834 if !is_indexable_ext(ext) {
835 continue;
836 }
837
838 let rel = make_relative(&file_path, &project_root);
839 if extra_ignores.iter().any(|p| p.matches(&rel)) {
840 continue;
841 }
842
843 if max_files != usize::MAX && index.files.len() >= max_files {
844 tracing::info!(
845 "[graph_index: reached configured limit of {} files. Set graph_index_max_files = 0 for unlimited.]",
846 max_files
847 );
848 break;
849 }
850
851 let Ok(content) = std::fs::read_to_string(&file_path) else {
852 continue;
853 };
854
855 let hash = compute_hash(&content);
856 let rel_path = make_relative(&file_path, &project_root);
857
858 if let Some((old_hash, old_syms)) = old_files.get(&rel_path) {
859 if *old_hash == hash {
860 if let Some(old_entry) = existing.as_ref().and_then(|p| p.files.get(&rel_path)) {
861 index.files.insert(rel_path.clone(), old_entry.clone());
862 for (key, sym) in old_syms {
863 index.symbols.insert(key.clone(), sym.clone());
864 }
865 content_cache.insert(rel_path, content);
866 reused += 1;
867 continue;
868 }
869 }
870 }
871
872 let sigs = signatures::extract_signatures(&content, ext);
873 let line_count = content.lines().count();
874 let token_count = crate::core::tokens::count_tokens(&content);
875 let summary = extract_summary(&content);
876
877 let exports: Vec<String> = sigs
878 .iter()
879 .filter(|s| s.is_exported)
880 .map(|s| s.name.clone())
881 .collect();
882
883 index.files.insert(
884 rel_path.clone(),
885 FileEntry {
886 path: rel_path.clone(),
887 hash,
888 language: ext.to_string(),
889 line_count,
890 token_count,
891 exports,
892 summary,
893 },
894 );
895
896 for sig in &sigs {
897 let (start, end) = sig
898 .start_line
899 .zip(sig.end_line)
900 .unwrap_or_else(|| find_symbol_range(&content, sig));
901 let key = format!("{}::{}", rel_path, sig.name);
902 index.symbols.insert(
903 key,
904 SymbolEntry {
905 file: rel_path.clone(),
906 name: sig.name.clone(),
907 kind: sig.kind.to_string(),
908 start_line: start,
909 end_line: end,
910 is_exported: sig.is_exported,
911 },
912 );
913 }
914
915 content_cache.insert(rel_path, content);
916 scanned += 1;
917 }
918
919 build_edges_cached(&mut index, &content_cache);
920
921 if let Err(e) = index.save() {
922 tracing::warn!("could not save graph index: {e}");
923 }
924
925 tracing::warn!(
926 "[graph_index: {} files ({} scanned, {} reused), {} symbols, {} edges]",
927 index.file_count(),
928 scanned,
929 reused,
930 index.symbol_count(),
931 index.edge_count()
932 );
933
934 (index, content_cache)
935}
936
937fn find_symbol_range(content: &str, sig: &signatures::Signature) -> (usize, usize) {
938 let lines: Vec<&str> = content.lines().collect();
939 let mut start = 0;
940
941 for (i, line) in lines.iter().enumerate() {
942 if line.contains(&sig.name) {
943 let trimmed = line.trim();
944 let is_def = trimmed.starts_with("fn ")
945 || trimmed.starts_with("pub fn ")
946 || trimmed.starts_with("pub(crate) fn ")
947 || trimmed.starts_with("async fn ")
948 || trimmed.starts_with("pub async fn ")
949 || trimmed.starts_with("struct ")
950 || trimmed.starts_with("pub struct ")
951 || trimmed.starts_with("enum ")
952 || trimmed.starts_with("pub enum ")
953 || trimmed.starts_with("trait ")
954 || trimmed.starts_with("pub trait ")
955 || trimmed.starts_with("impl ")
956 || trimmed.starts_with("class ")
957 || trimmed.starts_with("export class ")
958 || trimmed.starts_with("export function ")
959 || trimmed.starts_with("export async function ")
960 || trimmed.starts_with("function ")
961 || trimmed.starts_with("async function ")
962 || trimmed.starts_with("def ")
963 || trimmed.starts_with("async def ")
964 || trimmed.starts_with("func ")
965 || trimmed.starts_with("interface ")
966 || trimmed.starts_with("export interface ")
967 || trimmed.starts_with("type ")
968 || trimmed.starts_with("export type ")
969 || trimmed.starts_with("const ")
970 || trimmed.starts_with("export const ")
971 || trimmed.starts_with("fun ")
972 || trimmed.starts_with("private fun ")
973 || trimmed.starts_with("public fun ")
974 || trimmed.starts_with("internal fun ")
975 || trimmed.starts_with("class ")
976 || trimmed.starts_with("data class ")
977 || trimmed.starts_with("sealed class ")
978 || trimmed.starts_with("sealed interface ")
979 || trimmed.starts_with("enum class ")
980 || trimmed.starts_with("object ")
981 || trimmed.starts_with("private object ")
982 || trimmed.starts_with("interface ")
983 || trimmed.starts_with("typealias ")
984 || trimmed.starts_with("private typealias ");
985 if is_def {
986 start = i + 1;
987 break;
988 }
989 }
990 }
991
992 if start == 0 {
993 return (1, lines.len().min(20));
994 }
995
996 let base_indent = lines
997 .get(start - 1)
998 .map_or(0, |l| l.len() - l.trim_start().len());
999
1000 let mut end = start;
1001 let mut brace_depth: i32 = 0;
1002 let mut found_open = false;
1003
1004 for (i, line) in lines.iter().enumerate().skip(start - 1) {
1005 for ch in line.chars() {
1006 if ch == '{' {
1007 brace_depth += 1;
1008 found_open = true;
1009 } else if ch == '}' {
1010 brace_depth -= 1;
1011 }
1012 }
1013
1014 end = i + 1;
1015
1016 if found_open && brace_depth <= 0 {
1017 break;
1018 }
1019
1020 if !found_open && i > start {
1021 let indent = line.len() - line.trim_start().len();
1022 if indent <= base_indent && !line.trim().is_empty() && i > start {
1023 end = i;
1024 break;
1025 }
1026 }
1027
1028 if end - start > 200 {
1029 break;
1030 }
1031 }
1032
1033 (start, end)
1034}
1035
1036fn extract_summary(content: &str) -> String {
1037 for line in content.lines().take(20) {
1038 let trimmed = line.trim();
1039 if trimmed.is_empty()
1040 || trimmed.starts_with("//")
1041 || trimmed.starts_with('#')
1042 || trimmed.starts_with("/*")
1043 || trimmed.starts_with('*')
1044 || trimmed.starts_with("use ")
1045 || trimmed.starts_with("import ")
1046 || trimmed.starts_with("from ")
1047 || trimmed.starts_with("require(")
1048 || trimmed.starts_with("package ")
1049 {
1050 continue;
1051 }
1052 return trimmed.chars().take(120).collect();
1053 }
1054 String::new()
1055}
1056
1057fn compute_hash(content: &str) -> String {
1058 use std::collections::hash_map::DefaultHasher;
1059 use std::hash::{Hash, Hasher};
1060
1061 let mut hasher = DefaultHasher::new();
1062 content.hash(&mut hasher);
1063 format!("{:016x}", hasher.finish())
1064}
1065
1066fn short_hash(input: &str) -> String {
1067 use std::collections::hash_map::DefaultHasher;
1068 use std::hash::{Hash, Hasher};
1069
1070 let mut hasher = DefaultHasher::new();
1071 input.hash(&mut hasher);
1072 format!("{:08x}", hasher.finish() & 0xFFFF_FFFF)
1073}
1074
1075fn copy_dir_fallible(src: &std::path::Path, dst: &std::path::Path) -> Result<(), std::io::Error> {
1076 std::fs::create_dir_all(dst)?;
1077 for entry in std::fs::read_dir(src)?.flatten() {
1078 let from = entry.path();
1079 let to = dst.join(entry.file_name());
1080 if from.is_dir() {
1081 copy_dir_fallible(&from, &to)?;
1082 } else {
1083 std::fs::copy(&from, &to)?;
1084 }
1085 }
1086 Ok(())
1087}
1088
1089fn normalize_absolute_path(path: &str) -> String {
1090 if let Ok(canon) = crate::core::pathutil::safe_canonicalize(std::path::Path::new(path)) {
1091 return canon.to_string_lossy().to_string();
1092 }
1093
1094 let mut normalized = path.to_string();
1095 while normalized.ends_with("\\.") || normalized.ends_with("/.") {
1096 normalized.truncate(normalized.len() - 2);
1097 }
1098 while normalized.len() > 1
1099 && (normalized.ends_with('\\') || normalized.ends_with('/'))
1100 && !normalized.ends_with(":\\")
1101 && !normalized.ends_with(":/")
1102 && normalized != "\\"
1103 && normalized != "/"
1104 {
1105 normalized.pop();
1106 }
1107 normalized
1108}
1109
1110pub fn normalize_project_root(path: &str) -> String {
1111 normalize_absolute_path(path)
1112}
1113
1114pub fn graph_match_key(path: &str) -> String {
1115 let stripped =
1116 crate::core::pathutil::strip_verbatim_str(path).unwrap_or_else(|| path.replace('\\', "/"));
1117 stripped.trim_start_matches('/').to_string()
1118}
1119
1120pub fn graph_relative_key(path: &str, root: &str) -> String {
1121 let root_norm = normalize_project_root(root);
1122 let path_norm = normalize_absolute_path(path);
1123 let root_path = Path::new(&root_norm);
1124 let path_path = Path::new(&path_norm);
1125
1126 if let Ok(rel) = path_path.strip_prefix(root_path) {
1127 let rel = rel.to_string_lossy().to_string();
1128 return rel.trim_start_matches(['/', '\\']).to_string();
1129 }
1130
1131 path.trim_start_matches(['/', '\\'])
1132 .replace('/', std::path::MAIN_SEPARATOR_STR)
1133}
1134
1135fn make_relative(path: &str, root: &str) -> String {
1136 graph_relative_key(path, root)
1137}
1138
1139fn is_indexable_ext(ext: &str) -> bool {
1140 crate::core::language_capabilities::is_indexable_ext(ext)
1141}
1142
1143#[cfg(test)]
1144fn kotlin_package_name(content: &str) -> Option<String> {
1145 content.lines().map(str::trim).find_map(|line| {
1146 line.strip_prefix("package ")
1147 .map(|rest| rest.trim().trim_end_matches(';').to_string())
1148 })
1149}