1use std::{
2 collections::HashMap,
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)>,
23 Vec<(NodeId, String)>,
24 Vec<(NodeId, String)>,
25 Vec<(NodeId, String)>,
26)>;
27
28pub struct IncrementalIndexer {
33 repo_root: PathBuf,
34 ignorer: Gitignore,
35}
36
37impl IncrementalIndexer {
38 pub fn new(repo_root: &Path) -> Result<Self> {
42 let ignorer = build_ignorer(repo_root);
43 Ok(Self {
44 repo_root: repo_root.to_owned(),
45 ignorer,
46 })
47 }
48
49 pub fn run(&self, from_sha: Option<&str>) -> Result<(GraphDiff, String)> {
57 let differ = Differ::open(&self.repo_root)?;
58 let head_sha = differ.head_sha()?;
59
60 if from_sha.map(|s| s == head_sha).unwrap_or(false) {
61 return Ok((GraphDiff::default(), head_sha));
62 }
63
64 let supported = self.supported_extensions();
65 let changes = differ.changed_files(from_sha, &supported)?;
66
67 if changes.is_empty() {
68 return Ok((GraphDiff::default(), head_sha));
69 }
70
71 let (to_parse, to_delete): (Vec<_>, Vec<_>) = changes
72 .into_iter()
73 .partition(|c| !matches!(c, FileChange::Deleted(_)));
74
75 let per_file: Vec<FileIndexResult> = to_parse
77 .par_iter()
78 .map(|change| self.index_file(change.path()))
79 .collect();
80
81 let mut merged = GraphDiff::default();
83 let mut all_calls: Vec<(NodeId, String)> = Vec::new();
84 let mut all_uses: Vec<(NodeId, String)> = Vec::new();
85 let mut all_implements: Vec<(NodeId, String)> = Vec::new();
86 let mut all_imports: Vec<(NodeId, String)> = Vec::new();
87 for result in per_file {
88 let (diff, calls, uses, implements, imports) = result?;
89 merged.merge(diff);
90 all_calls.extend(calls);
91 all_uses.extend(uses);
92 all_implements.extend(implements);
93 all_imports.extend(imports);
94 }
95
96 let name_to_ids: HashMap<&str, Vec<&NodeId>> = {
99 let mut map: HashMap<&str, Vec<&NodeId>> = HashMap::new();
100 for node in &merged.added_nodes {
101 map.entry(node.name.as_str()).or_default().push(&node.id);
102 }
103 map
104 };
105
106 merged.deferred_calls = resolve_deferred(
107 &name_to_ids,
108 &all_calls,
109 EdgeKind::Calls,
110 &mut merged.added_edges,
111 );
112 merged.deferred_uses = resolve_deferred(
113 &name_to_ids,
114 &all_uses,
115 EdgeKind::Uses,
116 &mut merged.added_edges,
117 );
118 merged.deferred_implements = resolve_deferred(
119 &name_to_ids,
120 &all_implements,
121 EdgeKind::Implements,
122 &mut merged.added_edges,
123 );
124 let _ = resolve_deferred(
127 &name_to_ids,
128 &all_imports,
129 EdgeKind::Imports,
130 &mut merged.added_edges,
131 );
132
133 for deleted in to_delete {
134 merged.removed_files.push(deleted.path().to_owned());
135 }
136
137 let (struct_nodes, struct_edges) = build_structural_nodes(&merged);
140 merged.added_nodes.extend(struct_nodes);
141 merged.added_edges.extend(struct_edges);
142
143 Ok((merged, head_sha))
144 }
145
146 fn supported_extensions(&self) -> Vec<&'static str> {
149 vec!["rs", "py", "ts", "tsx", "js", "jsx", "mjs", "cjs", "go"]
150 }
151
152 fn index_file(&self, repo_relative_path: &Path) -> FileIndexResult {
153 let abs_path = self.repo_root.join(repo_relative_path);
154 let empty = || {
155 (
156 GraphDiff::default(),
157 Vec::new(),
158 Vec::new(),
159 Vec::new(),
160 Vec::new(),
161 )
162 };
163
164 if self.should_ignore(repo_relative_path) {
165 return Ok(empty());
166 }
167
168 let source = fs::read_to_string(&abs_path).map_err(|e| GitCortexError::Parse {
169 file: abs_path.clone(),
170 message: e.to_string(),
171 })?;
172
173 if source.len() > 512 * 1024 {
174 return Ok(empty());
175 }
176
177 let parser = match parser_for_path(repo_relative_path) {
178 Some(p) => p,
179 None => return Ok(empty()),
180 };
181
182 let ParseResult {
183 nodes,
184 edges,
185 deferred_calls,
186 deferred_uses,
187 deferred_implements,
188 deferred_imports,
189 } = parser.parse(repo_relative_path, &source)?;
190
191 let mut diff = GraphDiff::default();
192 diff.removed_files.push(repo_relative_path.to_owned());
195 for ancestor in repo_relative_path.ancestors().skip(1) {
196 if ancestor == Path::new("") || ancestor == Path::new(".") {
197 break;
198 }
199 diff.removed_files.push(ancestor.to_path_buf());
200 }
201 diff.added_nodes = nodes;
202 diff.added_edges = edges;
203
204 Ok((
205 diff,
206 deferred_calls,
207 deferred_uses,
208 deferred_implements,
209 deferred_imports,
210 ))
211 }
212
213 fn should_ignore(&self, path: &Path) -> bool {
214 self.ignorer
215 .matched_path_or_any_parents(path, false)
216 .is_ignore()
217 }
218}
219
220fn resolve_deferred(
228 name_to_ids: &HashMap<&str, Vec<&NodeId>>,
229 deferred: &[(NodeId, String)],
230 kind: EdgeKind,
231 edges: &mut Vec<Edge>,
232) -> Vec<(NodeId, String)> {
233 let mut unresolved = Vec::new();
234 for (src_id, target_name) in deferred {
235 if let Some(dst_ids) = name_to_ids.get(target_name.as_str()) {
236 for dst_id in dst_ids {
237 let edge = Edge {
238 src: src_id.clone(),
239 dst: (*dst_id).clone(),
240 kind: kind.clone(),
241 };
242 if !edges.contains(&edge) {
243 edges.push(edge);
244 }
245 }
246 } else {
247 unresolved.push((src_id.clone(), target_name.clone()));
248 }
249 }
250 unresolved
251}
252
253fn build_structural_nodes(diff: &GraphDiff) -> (Vec<Node>, Vec<Edge>) {
262 use std::collections::HashSet;
263
264 let contained: HashSet<&NodeId> = diff
266 .added_edges
267 .iter()
268 .filter(|e| e.kind == EdgeKind::Contains)
269 .map(|e| &e.dst)
270 .collect();
271
272 let mut by_file: HashMap<&Path, Vec<&Node>> = HashMap::new();
274 for node in &diff.added_nodes {
275 by_file.entry(node.file.as_path()).or_default().push(node);
276 }
277
278 let mut new_nodes: Vec<Node> = Vec::new();
279 let mut new_edges: Vec<Edge> = Vec::new();
280 let mut folder_ids: HashMap<PathBuf, NodeId> = HashMap::new();
282
283 let mut file_ids: HashMap<&Path, NodeId> = HashMap::new();
285 for (file_path, code_nodes) in &by_file {
286 let file_id = NodeId::new();
287 let name = file_path
288 .file_name()
289 .and_then(|n| n.to_str())
290 .unwrap_or("")
291 .to_owned();
292 new_nodes.push(Node {
293 id: file_id.clone(),
294 name,
295 kind: NodeKind::File,
296 qualified_name: file_path.to_string_lossy().into_owned(),
297 file: file_path.to_path_buf(),
298 span: Span {
299 start_line: 1,
300 end_line: 1,
301 },
302 metadata: NodeMetadata {
303 visibility: Visibility::Pub,
304 ..Default::default()
305 },
306 });
307 file_ids.insert(file_path, file_id.clone());
308
309 for node in code_nodes {
311 if !contained.contains(&node.id) {
312 new_edges.push(Edge {
313 src: file_id.clone(),
314 dst: node.id.clone(),
315 kind: EdgeKind::Contains,
316 });
317 }
318 }
319 }
320
321 let unique_dirs: HashSet<PathBuf> = by_file
323 .keys()
324 .flat_map(|p| p.ancestors().skip(1))
325 .filter(|p| *p != Path::new("") && *p != Path::new("."))
326 .map(|p| p.to_path_buf())
327 .collect();
328
329 for dir in &unique_dirs {
330 let dir_id = folder_ids.entry(dir.clone()).or_default().clone();
331 let name = dir
332 .file_name()
333 .and_then(|n| n.to_str())
334 .unwrap_or(dir.to_str().unwrap_or(""))
335 .to_owned();
336 new_nodes.push(Node {
337 id: dir_id,
338 name,
339 kind: NodeKind::Folder,
340 qualified_name: dir.to_string_lossy().into_owned(),
341 file: dir.clone(),
342 span: Span {
343 start_line: 1,
344 end_line: 1,
345 },
346 metadata: NodeMetadata {
347 visibility: Visibility::Pub,
348 ..Default::default()
349 },
350 });
351 }
352
353 for (file_path, file_id) in &file_ids {
355 if let Some(parent) = file_path.parent().filter(|p| *p != Path::new("")) {
356 if let Some(dir_id) = folder_ids.get(parent) {
357 new_edges.push(Edge {
358 src: dir_id.clone(),
359 dst: file_id.clone(),
360 kind: EdgeKind::Contains,
361 });
362 }
363 }
364 }
365 for dir in &unique_dirs {
366 let child_id = folder_ids[dir].clone();
367 if let Some(parent) = dir
368 .parent()
369 .filter(|p| *p != Path::new("") && *p != Path::new("."))
370 {
371 if let Some(parent_id) = folder_ids.get(parent) {
372 new_edges.push(Edge {
373 src: parent_id.clone(),
374 dst: child_id,
375 kind: EdgeKind::Contains,
376 });
377 }
378 }
379 }
380
381 (new_nodes, new_edges)
382}
383
384fn build_ignorer(repo_root: &Path) -> Gitignore {
385 let ignore_path = repo_root.join(".gitcortex/ignore");
386 let mut builder = GitignoreBuilder::new(repo_root);
387 if ignore_path.exists() {
388 let _ = builder.add(ignore_path);
389 }
390 builder.build().unwrap_or_else(|_| Gitignore::empty())
391}