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