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