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::{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)>, 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)> = 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_deferred(
154 &name_to_ids,
155 &id_to_file,
156 &all_calls,
157 EdgeKind::Calls,
158 &mut merged.added_edges,
159 &mut seen_edges,
160 );
161 merged.deferred_uses = resolve_deferred(
162 &name_to_ids,
163 &id_to_file,
164 &all_uses,
165 EdgeKind::Uses,
166 &mut merged.added_edges,
167 &mut seen_edges,
168 );
169 merged.deferred_implements = resolve_deferred(
170 &name_to_ids,
171 &id_to_file,
172 &all_implements,
173 EdgeKind::Implements,
174 &mut merged.added_edges,
175 &mut seen_edges,
176 );
177 let _ = resolve_deferred(
180 &name_to_ids,
181 &id_to_file,
182 &all_imports,
183 EdgeKind::Imports,
184 &mut merged.added_edges,
185 &mut seen_edges,
186 );
187 merged.deferred_inherits = resolve_deferred(
188 &name_to_ids,
189 &id_to_file,
190 &all_inherits,
191 EdgeKind::Inherits,
192 &mut merged.added_edges,
193 &mut seen_edges,
194 );
195 merged.deferred_throws = resolve_deferred(
196 &name_to_ids,
197 &id_to_file,
198 &all_throws,
199 EdgeKind::Throws,
200 &mut merged.added_edges,
201 &mut seen_edges,
202 );
203 merged.deferred_annotated = resolve_deferred(
204 &name_to_ids,
205 &id_to_file,
206 &all_annotated,
207 EdgeKind::Annotated,
208 &mut merged.added_edges,
209 &mut seen_edges,
210 );
211 mark!(format!(
212 "resolve_deferred done: {} total edges",
213 merged.added_edges.len()
214 ));
215
216 for deleted in to_delete {
217 merged.removed_files.push(deleted.path().to_owned());
218 }
219
220 let (struct_nodes, struct_edges) = build_structural_nodes(&merged);
223 merged.added_nodes.extend(struct_nodes);
224 merged.added_edges.extend(struct_edges);
225 mark!("structural nodes done");
226
227 Ok((merged, head_sha))
228 }
229
230 fn supported_extensions(&self) -> Vec<&'static str> {
233 vec![
234 "rs", "py", "ts", "tsx", "js", "jsx", "mjs", "cjs", "go", "java",
235 ]
236 }
237
238 fn index_file(&self, repo_relative_path: &Path) -> FileIndexResult {
239 let abs_path = self.repo_root.join(repo_relative_path);
240 let empty = || {
241 (
242 GraphDiff::default(),
243 Vec::<(NodeId, String)>::new(),
244 Vec::<(NodeId, String)>::new(),
245 Vec::<(NodeId, String)>::new(),
246 Vec::<(NodeId, String)>::new(),
247 Vec::<(NodeId, String)>::new(),
248 Vec::<(NodeId, String)>::new(),
249 Vec::<(NodeId, String)>::new(),
250 )
251 };
252
253 if self.should_ignore(repo_relative_path) {
254 return Ok(empty());
255 }
256
257 let source = fs::read_to_string(&abs_path).map_err(|e| GitCortexError::Parse {
258 file: abs_path.clone(),
259 message: e.to_string(),
260 })?;
261
262 if source.len() > 512 * 1024 {
263 return Ok(empty());
264 }
265
266 let parser = match parser_for_path(repo_relative_path) {
267 Some(p) => p,
268 None => return Ok(empty()),
269 };
270
271 let ParseResult {
272 nodes,
273 edges,
274 deferred_calls,
275 deferred_uses,
276 deferred_implements,
277 deferred_imports,
278 deferred_inherits,
279 deferred_throws,
280 deferred_annotated,
281 } = parser.parse(repo_relative_path, &source)?;
282
283 let mut diff = GraphDiff::default();
284 diff.removed_files.push(repo_relative_path.to_owned());
287 for ancestor in repo_relative_path.ancestors().skip(1) {
288 if ancestor == Path::new("") || ancestor == Path::new(".") {
289 break;
290 }
291 diff.removed_files.push(ancestor.to_path_buf());
292 }
293 diff.added_nodes = nodes;
294 diff.added_edges = edges;
295
296 Ok((
297 diff,
298 deferred_calls,
299 deferred_uses,
300 deferred_implements,
301 deferred_imports,
302 deferred_inherits,
303 deferred_throws,
304 deferred_annotated,
305 ))
306 }
307
308 fn should_ignore(&self, path: &Path) -> bool {
309 self.ignorer
310 .matched_path_or_any_parents(path, false)
311 .is_ignore()
312 }
313}
314
315const MAX_RESOLVE_FANOUT: usize = 8;
324
325fn resolve_deferred(
338 name_to_ids: &HashMap<&str, Vec<&NodeId>>,
339 id_to_file: &HashMap<&NodeId, &Path>,
340 deferred: &[(NodeId, String)],
341 kind: EdgeKind,
342 edges: &mut Vec<Edge>,
343 seen: &mut HashSet<(String, String, String)>,
344) -> Vec<(NodeId, String)> {
345 let mut unresolved = Vec::new();
346 for (src_id, target_name) in deferred {
347 let src_exts = id_to_file
348 .get(src_id)
349 .and_then(|p| language_extensions_for_path(p));
350
351 let Some(dst_ids) = name_to_ids.get(target_name.as_str()) else {
352 unresolved.push((src_id.clone(), target_name.clone()));
353 continue;
354 };
355
356 let candidates: Vec<&NodeId> = dst_ids
358 .iter()
359 .copied()
360 .filter(|dst_id| match (src_exts, id_to_file.get(*dst_id)) {
361 (Some(exts), Some(dst_path)) => language_extensions_for_path(dst_path)
362 .map(|dst_exts| dst_exts.first() == exts.first())
363 .unwrap_or(true),
364 _ => true,
365 })
366 .collect();
367
368 if candidates.len() > MAX_RESOLVE_FANOUT {
371 continue;
372 }
373
374 let mut matched_any = false;
375 for dst_id in candidates {
376 matched_any = true;
377 let key = (src_id.as_str(), dst_id.as_str(), kind.to_string());
378 if seen.insert(key) {
379 edges.push(Edge {
380 src: src_id.clone(),
381 dst: dst_id.clone(),
382 kind: kind.clone(),
383 });
384 }
385 }
386 if !matched_any {
387 unresolved.push((src_id.clone(), target_name.clone()));
388 }
389 }
390 unresolved
391}
392
393fn language_extensions_for_path(path: &Path) -> Option<&'static [&'static str]> {
396 let ext = path.extension()?.to_str()?;
397 match ext {
398 "rs" => Some(&[".rs"]),
399 "py" => Some(&[".py"]),
400 "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
401 Some(&[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"])
402 }
403 "go" => Some(&[".go"]),
404 "java" => Some(&[".java"]),
405 _ => None,
406 }
407}
408
409fn build_structural_nodes(diff: &GraphDiff) -> (Vec<Node>, Vec<Edge>) {
418 use std::collections::HashSet;
419
420 let contained: HashSet<&NodeId> = diff
422 .added_edges
423 .iter()
424 .filter(|e| e.kind == EdgeKind::Contains)
425 .map(|e| &e.dst)
426 .collect();
427
428 let mut by_file: HashMap<&Path, Vec<&Node>> = HashMap::new();
430 for node in &diff.added_nodes {
431 by_file.entry(node.file.as_path()).or_default().push(node);
432 }
433
434 let mut new_nodes: Vec<Node> = Vec::new();
435 let mut new_edges: Vec<Edge> = Vec::new();
436 let mut folder_ids: HashMap<PathBuf, NodeId> = HashMap::new();
438
439 let mut file_ids: HashMap<&Path, NodeId> = HashMap::new();
441 for (file_path, code_nodes) in &by_file {
442 let file_id = NodeId::new();
443 let name = file_path
444 .file_name()
445 .and_then(|n| n.to_str())
446 .unwrap_or("")
447 .to_owned();
448 new_nodes.push(Node {
449 id: file_id.clone(),
450 name,
451 kind: NodeKind::File,
452 qualified_name: file_path.to_string_lossy().into_owned(),
453 file: file_path.to_path_buf(),
454 span: Span {
455 start_line: 1,
456 end_line: 1,
457 },
458 metadata: NodeMetadata {
459 visibility: Visibility::Pub,
460 ..Default::default()
461 },
462 });
463 file_ids.insert(file_path, file_id.clone());
464
465 for node in code_nodes {
467 if !contained.contains(&node.id) {
468 new_edges.push(Edge {
469 src: file_id.clone(),
470 dst: node.id.clone(),
471 kind: EdgeKind::Contains,
472 });
473 }
474 }
475 }
476
477 let unique_dirs: HashSet<PathBuf> = by_file
479 .keys()
480 .flat_map(|p| p.ancestors().skip(1))
481 .filter(|p| *p != Path::new("") && *p != Path::new("."))
482 .map(|p| p.to_path_buf())
483 .collect();
484
485 for dir in &unique_dirs {
486 let dir_id = folder_ids.entry(dir.clone()).or_default().clone();
487 let name = dir
488 .file_name()
489 .and_then(|n| n.to_str())
490 .unwrap_or(dir.to_str().unwrap_or(""))
491 .to_owned();
492 new_nodes.push(Node {
493 id: dir_id,
494 name,
495 kind: NodeKind::Folder,
496 qualified_name: dir.to_string_lossy().into_owned(),
497 file: dir.clone(),
498 span: Span {
499 start_line: 1,
500 end_line: 1,
501 },
502 metadata: NodeMetadata {
503 visibility: Visibility::Pub,
504 ..Default::default()
505 },
506 });
507 }
508
509 for (file_path, file_id) in &file_ids {
511 if let Some(parent) = file_path.parent().filter(|p| *p != Path::new("")) {
512 if let Some(dir_id) = folder_ids.get(parent) {
513 new_edges.push(Edge {
514 src: dir_id.clone(),
515 dst: file_id.clone(),
516 kind: EdgeKind::Contains,
517 });
518 }
519 }
520 }
521 for dir in &unique_dirs {
522 let child_id = folder_ids[dir].clone();
523 if let Some(parent) = dir
524 .parent()
525 .filter(|p| *p != Path::new("") && *p != Path::new("."))
526 {
527 if let Some(parent_id) = folder_ids.get(parent) {
528 new_edges.push(Edge {
529 src: parent_id.clone(),
530 dst: child_id,
531 kind: EdgeKind::Contains,
532 });
533 }
534 }
535 }
536
537 (new_nodes, new_edges)
538}
539
540fn build_ignorer(repo_root: &Path) -> Gitignore {
541 let ignore_path = repo_root.join(".gitcortex/ignore");
542 let mut builder = GitignoreBuilder::new(repo_root);
543 if ignore_path.exists() {
544 let _ = builder.add(ignore_path);
545 }
546 builder.build().unwrap_or_else(|_| Gitignore::empty())
547}