1use std::{
2 collections::{HashMap, HashSet},
3 fs,
4 path::{Path, PathBuf},
5};
6
7use gitcortex_core::{
8 error::{GitCortexError, Result},
9 graph::{Edge, GraphDiff, Node, NodeId, NodeMetadata, Span},
10 schema::{EdgeConfidence, EdgeKind, NodeKind, Visibility},
11};
12use ignore::gitignore::{Gitignore, GitignoreBuilder};
13use rayon::prelude::*;
14
15use crate::{
16 differ::{Differ, FileChange},
17 parser::{parser_for_path, ParseResult},
18};
19
20type FileIndexResult = Result<(
21 GraphDiff,
22 Vec<(NodeId, String, u32)>, Vec<(NodeId, String)>, Vec<(NodeId, String)>, Vec<(NodeId, String)>, Vec<(NodeId, String)>, Vec<(NodeId, String)>, Vec<(NodeId, String)>, Vec<(NodeId, String)>, )>;
31
32pub struct IncrementalIndexer {
37 repo_root: PathBuf,
38 ignorer: Gitignore,
39}
40
41impl IncrementalIndexer {
42 pub fn new(repo_root: &Path) -> Result<Self> {
46 let ignorer = build_ignorer(repo_root);
47 Ok(Self {
48 repo_root: repo_root.to_owned(),
49 ignorer,
50 })
51 }
52
53 pub fn run(&self, from_sha: Option<&str>) -> Result<(GraphDiff, String)> {
61 let differ = Differ::open(&self.repo_root)?;
62 let head_sha = differ.head_sha()?;
63
64 if from_sha.map(|s| s == head_sha).unwrap_or(false) {
65 return Ok((GraphDiff::default(), head_sha));
66 }
67
68 let timing = std::env::var_os("GCX_TIMING").is_some();
69 let t0 = std::time::Instant::now();
70 macro_rules! mark {
71 ($label:expr) => {
72 if timing {
73 eprintln!("[gcx-timing] {:>8.2?} {}", t0.elapsed(), $label);
74 }
75 };
76 }
77
78 let supported = self.supported_extensions();
79 let changes = differ.changed_files(from_sha, &supported)?;
80
81 if changes.is_empty() {
82 return Ok((GraphDiff::default(), head_sha));
83 }
84
85 let (to_parse, to_delete): (Vec<_>, Vec<_>) = changes
86 .into_iter()
87 .partition(|c| !matches!(c, FileChange::Deleted(_)));
88 mark!(format!("diff: {} files to parse", to_parse.len()));
89
90 let per_file: Vec<FileIndexResult> = to_parse
92 .par_iter()
93 .map(|change| self.index_file(change.path()))
94 .collect();
95 mark!("parse (parallel) done");
96
97 let mut merged = GraphDiff::default();
99 let mut all_calls: Vec<(NodeId, String, u32)> = Vec::new();
100 let mut all_uses: Vec<(NodeId, String)> = Vec::new();
101 let mut all_implements: Vec<(NodeId, String)> = Vec::new();
102 let mut all_imports: Vec<(NodeId, String)> = Vec::new();
103 let mut all_inherits: Vec<(NodeId, String)> = Vec::new();
104 let mut all_throws: Vec<(NodeId, String)> = Vec::new();
105 let mut all_annotated: Vec<(NodeId, String)> = Vec::new();
106 let mut all_doc_refs: Vec<(NodeId, String)> = Vec::new();
107 for result in per_file {
108 let (diff, calls, uses, implements, imports, inherits, throws, annotated, doc_refs) =
109 result?;
110 merged.merge(diff);
111 all_calls.extend(calls);
112 all_uses.extend(uses);
113 all_implements.extend(implements);
114 all_imports.extend(imports);
115 all_inherits.extend(inherits);
116 all_throws.extend(throws);
117 all_annotated.extend(annotated);
118 all_doc_refs.extend(doc_refs);
119 }
120 mark!(format!(
121 "merge done: {} nodes, {} direct edges, {} deferred calls",
122 merged.added_nodes.len(),
123 merged.added_edges.len(),
124 all_calls.len()
125 ));
126
127 let name_to_ids: HashMap<&str, Vec<&NodeId>> = {
130 let mut map: HashMap<&str, Vec<&NodeId>> = HashMap::new();
131 for node in &merged.added_nodes {
132 map.entry(node.name.as_str()).or_default().push(&node.id);
133 }
134 map
135 };
136
137 let id_to_file: HashMap<&NodeId, &Path> = merged
142 .added_nodes
143 .iter()
144 .map(|n| (&n.id, n.file.as_path()))
145 .collect();
146
147 let mut seen_edges: HashSet<(String, String, String)> = merged
152 .added_edges
153 .iter()
154 .map(|e| (e.src.as_str(), e.dst.as_str(), e.kind.to_string()))
155 .collect();
156
157 merged.deferred_calls = resolve_calls(
158 &name_to_ids,
159 &id_to_file,
160 &all_calls,
161 &mut merged.added_edges,
162 &mut seen_edges,
163 );
164 merged.deferred_uses = resolve_deferred(
165 &name_to_ids,
166 &id_to_file,
167 &all_uses,
168 EdgeKind::Uses,
169 &mut merged.added_edges,
170 &mut seen_edges,
171 );
172 merged.deferred_implements = resolve_deferred(
173 &name_to_ids,
174 &id_to_file,
175 &all_implements,
176 EdgeKind::Implements,
177 &mut merged.added_edges,
178 &mut seen_edges,
179 );
180 let _ = resolve_deferred(
183 &name_to_ids,
184 &id_to_file,
185 &all_imports,
186 EdgeKind::Imports,
187 &mut merged.added_edges,
188 &mut seen_edges,
189 );
190 merged.deferred_inherits = resolve_deferred(
191 &name_to_ids,
192 &id_to_file,
193 &all_inherits,
194 EdgeKind::Inherits,
195 &mut merged.added_edges,
196 &mut seen_edges,
197 );
198 merged.deferred_throws = resolve_deferred(
199 &name_to_ids,
200 &id_to_file,
201 &all_throws,
202 EdgeKind::Throws,
203 &mut merged.added_edges,
204 &mut seen_edges,
205 );
206 merged.deferred_annotated = resolve_deferred(
207 &name_to_ids,
208 &id_to_file,
209 &all_annotated,
210 EdgeKind::Annotated,
211 &mut merged.added_edges,
212 &mut seen_edges,
213 );
214 merged.deferred_doc_refs = resolve_deferred(
219 &name_to_ids,
220 &id_to_file,
221 &all_doc_refs,
222 EdgeKind::References,
223 &mut merged.added_edges,
224 &mut seen_edges,
225 );
226 mark!(format!(
227 "resolve_deferred done: {} total edges",
228 merged.added_edges.len()
229 ));
230
231 {
237 let mut ann_by_id: HashMap<String, Vec<String>> = HashMap::new();
238 for (node_id, name) in &all_annotated {
239 ann_by_id
240 .entry(node_id.as_str())
241 .or_default()
242 .push(name.clone());
243 }
244 for node in &mut merged.added_nodes {
245 if let Some(names) = ann_by_id.get(&node.id.as_str()) {
246 let mut seen: HashSet<String> = HashSet::new();
247 node.metadata.annotations = names
248 .iter()
249 .filter(|n| seen.insert((*n).clone()))
250 .cloned()
251 .collect();
252 }
253 }
254 }
255
256 for deleted in to_delete {
257 merged.removed_files.push(deleted.path().to_owned());
258 }
259
260 let (struct_nodes, struct_edges) = build_structural_nodes(&merged);
263 merged.added_nodes.extend(struct_nodes);
264 merged.added_edges.extend(struct_edges);
265 mark!("structural nodes done");
266
267 Ok((merged, head_sha))
268 }
269
270 fn supported_extensions(&self) -> Vec<&'static str> {
273 vec![
274 "rs", "py", "ts", "tsx", "js", "jsx", "mjs", "cjs", "go", "java", "md", "markdown",
275 ]
276 }
277
278 fn index_file(&self, repo_relative_path: &Path) -> FileIndexResult {
279 let abs_path = self.repo_root.join(repo_relative_path);
280 let empty = || {
281 (
282 GraphDiff::default(),
283 Vec::<(NodeId, String, u32)>::new(),
284 Vec::<(NodeId, String)>::new(),
285 Vec::<(NodeId, String)>::new(),
286 Vec::<(NodeId, String)>::new(),
287 Vec::<(NodeId, String)>::new(),
288 Vec::<(NodeId, String)>::new(),
289 Vec::<(NodeId, String)>::new(),
290 Vec::<(NodeId, String)>::new(),
291 )
292 };
293
294 if self.should_ignore(repo_relative_path) {
295 return Ok(empty());
296 }
297
298 let source = fs::read_to_string(&abs_path).map_err(|e| GitCortexError::Parse {
299 file: abs_path.clone(),
300 message: e.to_string(),
301 })?;
302
303 if source.len() > 512 * 1024 {
304 return Ok(empty());
305 }
306
307 let parser = match parser_for_path(repo_relative_path) {
308 Some(p) => p,
309 None => return Ok(empty()),
310 };
311
312 let ParseResult {
313 nodes,
314 edges,
315 deferred_calls,
316 deferred_uses,
317 deferred_implements,
318 deferred_imports,
319 deferred_inherits,
320 deferred_throws,
321 deferred_annotated,
322 deferred_doc_refs,
323 } = parser.parse(repo_relative_path, &source)?;
324
325 let mut diff = GraphDiff::default();
326 diff.removed_files.push(repo_relative_path.to_owned());
329 for ancestor in repo_relative_path.ancestors().skip(1) {
330 if ancestor == Path::new("") || ancestor == Path::new(".") {
331 break;
332 }
333 diff.removed_files.push(ancestor.to_path_buf());
334 }
335 diff.added_nodes = nodes;
336 diff.added_edges = edges;
337
338 Ok((
339 diff,
340 deferred_calls,
341 deferred_uses,
342 deferred_implements,
343 deferred_imports,
344 deferred_inherits,
345 deferred_throws,
346 deferred_annotated,
347 deferred_doc_refs,
348 ))
349 }
350
351 fn should_ignore(&self, path: &Path) -> bool {
352 self.ignorer
353 .matched_path_or_any_parents(path, false)
354 .is_ignore()
355 }
356}
357
358const MAX_RESOLVE_FANOUT: usize = 8;
367
368fn resolve_deferred(
381 name_to_ids: &HashMap<&str, Vec<&NodeId>>,
382 id_to_file: &HashMap<&NodeId, &Path>,
383 deferred: &[(NodeId, String)],
384 kind: EdgeKind,
385 edges: &mut Vec<Edge>,
386 seen: &mut HashSet<(String, String, String)>,
387) -> Vec<(NodeId, String)> {
388 let mut unresolved = Vec::new();
389 for (src_id, target_name) in deferred {
390 let src_exts = id_to_file
391 .get(src_id)
392 .and_then(|p| language_extensions_for_path(p));
393
394 let Some(dst_ids) = name_to_ids.get(target_name.as_str()) else {
395 unresolved.push((src_id.clone(), target_name.clone()));
396 continue;
397 };
398
399 let candidates: Vec<&NodeId> = dst_ids
401 .iter()
402 .copied()
403 .filter(|dst_id| match (src_exts, id_to_file.get(*dst_id)) {
404 (Some(exts), Some(dst_path)) => language_extensions_for_path(dst_path)
405 .map(|dst_exts| dst_exts.first() == exts.first())
406 .unwrap_or(true),
407 _ => true,
408 })
409 .collect();
410
411 if candidates.len() > MAX_RESOLVE_FANOUT {
414 continue;
415 }
416
417 let mut matched_any = false;
418 for dst_id in candidates {
419 matched_any = true;
420 let key = (src_id.as_str(), dst_id.as_str(), kind.to_string());
421 if seen.insert(key) {
422 edges.push(
425 Edge::new(src_id.clone(), dst_id.clone(), kind.clone())
426 .with_confidence(EdgeConfidence::Inferred),
427 );
428 }
429 }
430 if !matched_any {
431 unresolved.push((src_id.clone(), target_name.clone()));
432 }
433 }
434 unresolved
435}
436
437fn resolve_calls(
442 name_to_ids: &HashMap<&str, Vec<&NodeId>>,
443 id_to_file: &HashMap<&NodeId, &Path>,
444 deferred: &[(NodeId, String, u32)],
445 edges: &mut Vec<Edge>,
446 seen: &mut HashSet<(String, String, String)>,
447) -> Vec<(NodeId, String, u32)> {
448 let mut unresolved = Vec::new();
449 for (src_id, target_name, line) in deferred {
450 let src_exts = id_to_file
451 .get(src_id)
452 .and_then(|p| language_extensions_for_path(p));
453
454 let Some(dst_ids) = name_to_ids.get(target_name.as_str()) else {
455 unresolved.push((src_id.clone(), target_name.clone(), *line));
456 continue;
457 };
458
459 let candidates: Vec<&NodeId> = dst_ids
460 .iter()
461 .copied()
462 .filter(|dst_id| match (src_exts, id_to_file.get(*dst_id)) {
463 (Some(exts), Some(dst_path)) => language_extensions_for_path(dst_path)
464 .map(|dst_exts| dst_exts.first() == exts.first())
465 .unwrap_or(true),
466 _ => true,
467 })
468 .collect();
469
470 if candidates.len() > MAX_RESOLVE_FANOUT {
471 continue;
472 }
473
474 let mut matched_any = false;
475 for dst_id in candidates {
476 matched_any = true;
477 let key = (
478 src_id.as_str(),
479 dst_id.as_str(),
480 EdgeKind::Calls.to_string(),
481 );
482 if seen.insert(key) {
483 edges.push(
484 Edge::call(src_id.clone(), dst_id.clone(), *line)
485 .with_confidence(EdgeConfidence::Inferred),
486 );
487 }
488 }
489 if !matched_any {
490 unresolved.push((src_id.clone(), target_name.clone(), *line));
491 }
492 }
493 unresolved
494}
495
496fn language_extensions_for_path(path: &Path) -> Option<&'static [&'static str]> {
499 let ext = path.extension()?.to_str()?;
500 match ext {
501 "rs" => Some(&[".rs"]),
502 "py" => Some(&[".py"]),
503 "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
504 Some(&[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"])
505 }
506 "go" => Some(&[".go"]),
507 "java" => Some(&[".java"]),
508 _ => None,
509 }
510}
511
512fn build_structural_nodes(diff: &GraphDiff) -> (Vec<Node>, Vec<Edge>) {
521 use std::collections::HashSet;
522
523 let contained: HashSet<&NodeId> = diff
525 .added_edges
526 .iter()
527 .filter(|e| e.kind == EdgeKind::Contains)
528 .map(|e| &e.dst)
529 .collect();
530
531 let mut by_file: HashMap<&Path, Vec<&Node>> = HashMap::new();
533 for node in &diff.added_nodes {
534 by_file.entry(node.file.as_path()).or_default().push(node);
535 }
536
537 let mut new_nodes: Vec<Node> = Vec::new();
538 let mut new_edges: Vec<Edge> = Vec::new();
539 let mut folder_ids: HashMap<PathBuf, NodeId> = HashMap::new();
541
542 let mut file_ids: HashMap<&Path, NodeId> = HashMap::new();
544 for (file_path, code_nodes) in &by_file {
545 let file_id = NodeId::new();
546 let name = file_path
547 .file_name()
548 .and_then(|n| n.to_str())
549 .unwrap_or("")
550 .to_owned();
551 new_nodes.push(Node {
552 id: file_id.clone(),
553 name,
554 kind: NodeKind::File,
555 qualified_name: file_path.to_string_lossy().into_owned(),
556 file: file_path.to_path_buf(),
557 span: Span {
558 start_line: 1,
559 end_line: 1,
560 },
561 metadata: NodeMetadata {
562 visibility: Visibility::Pub,
563 ..Default::default()
564 },
565 });
566 file_ids.insert(file_path, file_id.clone());
567
568 for node in code_nodes {
570 if !contained.contains(&node.id) {
571 new_edges.push(Edge {
572 src: file_id.clone(),
573 dst: node.id.clone(),
574 kind: EdgeKind::Contains,
575 line: None,
576 confidence: EdgeConfidence::Extracted,
577 });
578 }
579 }
580 }
581
582 let unique_dirs: HashSet<PathBuf> = by_file
584 .keys()
585 .flat_map(|p| p.ancestors().skip(1))
586 .filter(|p| *p != Path::new("") && *p != Path::new("."))
587 .map(|p| p.to_path_buf())
588 .collect();
589
590 for dir in &unique_dirs {
591 let dir_id = folder_ids.entry(dir.clone()).or_default().clone();
592 let name = dir
593 .file_name()
594 .and_then(|n| n.to_str())
595 .unwrap_or(dir.to_str().unwrap_or(""))
596 .to_owned();
597 new_nodes.push(Node {
598 id: dir_id,
599 name,
600 kind: NodeKind::Folder,
601 qualified_name: dir.to_string_lossy().into_owned(),
602 file: dir.clone(),
603 span: Span {
604 start_line: 1,
605 end_line: 1,
606 },
607 metadata: NodeMetadata {
608 visibility: Visibility::Pub,
609 ..Default::default()
610 },
611 });
612 }
613
614 for (file_path, file_id) in &file_ids {
616 if let Some(parent) = file_path.parent().filter(|p| *p != Path::new("")) {
617 if let Some(dir_id) = folder_ids.get(parent) {
618 new_edges.push(Edge {
619 src: dir_id.clone(),
620 dst: file_id.clone(),
621 kind: EdgeKind::Contains,
622 line: None,
623 confidence: EdgeConfidence::Extracted,
624 });
625 }
626 }
627 }
628 for dir in &unique_dirs {
629 let child_id = folder_ids[dir].clone();
630 if let Some(parent) = dir
631 .parent()
632 .filter(|p| *p != Path::new("") && *p != Path::new("."))
633 {
634 if let Some(parent_id) = folder_ids.get(parent) {
635 new_edges.push(Edge {
636 src: parent_id.clone(),
637 dst: child_id,
638 kind: EdgeKind::Contains,
639 line: None,
640 confidence: EdgeConfidence::Extracted,
641 });
642 }
643 }
644 }
645
646 (new_nodes, new_edges)
647}
648
649fn build_ignorer(repo_root: &Path) -> Gitignore {
650 let ignore_path = repo_root.join(".gitcortex/ignore");
651 let mut builder = GitignoreBuilder::new(repo_root);
652 if ignore_path.exists() {
653 let _ = builder.add(ignore_path);
654 }
655 builder.build().unwrap_or_else(|_| Gitignore::empty())
656}