Skip to main content

gitcortex_indexer/
indexer.rs

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)>, // deferred_calls
23    Vec<(NodeId, String)>, // deferred_uses
24    Vec<(NodeId, String)>, // deferred_implements
25    Vec<(NodeId, String)>, // deferred_imports
26    Vec<(NodeId, String)>, // deferred_inherits
27    Vec<(NodeId, String)>, // deferred_throws
28    Vec<(NodeId, String)>, // deferred_annotated
29)>;
30
31// ── IncrementalIndexer ────────────────────────────────────────────────────────
32
33/// Orchestrates the differ and parser to produce a `GraphDiff` from
34/// `last_sha..HEAD`. Stateless — callers own the repo root and last SHA.
35pub struct IncrementalIndexer {
36    repo_root: PathBuf,
37    ignorer: Gitignore,
38}
39
40impl IncrementalIndexer {
41    /// Build an indexer rooted at `repo_root`.
42    ///
43    /// Reads `.gitcortex/ignore` (if present) for exclusion patterns.
44    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    /// Run an incremental index from `from_sha` (exclusive) to HEAD.
53    ///
54    /// Returns:
55    /// - `GraphDiff` — changes to apply to the store
56    /// - `String`    — the HEAD SHA to persist as `last_indexed_sha`
57    ///
58    /// When `from_sha` is `None` the entire HEAD tree is indexed (first run).
59    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 supported = self.supported_extensions();
68        let changes = differ.changed_files(from_sha, &supported)?;
69
70        if changes.is_empty() {
71            return Ok((GraphDiff::default(), head_sha));
72        }
73
74        let (to_parse, to_delete): (Vec<_>, Vec<_>) = changes
75            .into_iter()
76            .partition(|c| !matches!(c, FileChange::Deleted(_)));
77
78        // Parse added/modified files in parallel.
79        let per_file: Vec<FileIndexResult> = to_parse
80            .par_iter()
81            .map(|change| self.index_file(change.path()))
82            .collect();
83
84        // Merge diffs and collect all deferred (cross-file) references.
85        let mut merged = GraphDiff::default();
86        let mut all_calls: Vec<(NodeId, String)> = Vec::new();
87        let mut all_uses: Vec<(NodeId, String)> = Vec::new();
88        let mut all_implements: Vec<(NodeId, String)> = Vec::new();
89        let mut all_imports: Vec<(NodeId, String)> = Vec::new();
90        let mut all_inherits: Vec<(NodeId, String)> = Vec::new();
91        let mut all_throws: Vec<(NodeId, String)> = Vec::new();
92        let mut all_annotated: Vec<(NodeId, String)> = Vec::new();
93        for result in per_file {
94            let (diff, calls, uses, implements, imports, inherits, throws, annotated) = result?;
95            merged.merge(diff);
96            all_calls.extend(calls);
97            all_uses.extend(uses);
98            all_implements.extend(implements);
99            all_imports.extend(imports);
100            all_inherits.extend(inherits);
101            all_throws.extend(throws);
102            all_annotated.extend(annotated);
103        }
104
105        // Build name → NodeId index from all nodes added in this diff.
106        // Used to resolve all four deferred edge types.
107        let name_to_ids: HashMap<&str, Vec<&NodeId>> = {
108            let mut map: HashMap<&str, Vec<&NodeId>> = HashMap::new();
109            for node in &merged.added_nodes {
110                map.entry(node.name.as_str()).or_default().push(&node.id);
111            }
112            map
113        };
114
115        merged.deferred_calls = resolve_deferred(
116            &name_to_ids,
117            &all_calls,
118            EdgeKind::Calls,
119            &mut merged.added_edges,
120        );
121        merged.deferred_uses = resolve_deferred(
122            &name_to_ids,
123            &all_uses,
124            EdgeKind::Uses,
125            &mut merged.added_edges,
126        );
127        merged.deferred_implements = resolve_deferred(
128            &name_to_ids,
129            &all_implements,
130            EdgeKind::Implements,
131            &mut merged.added_edges,
132        );
133        // Imports use placeholder src IDs so we can't resolve them against the store;
134        // resolve what we can locally and silently drop the rest.
135        let _ = resolve_deferred(
136            &name_to_ids,
137            &all_imports,
138            EdgeKind::Imports,
139            &mut merged.added_edges,
140        );
141        merged.deferred_inherits = resolve_deferred(
142            &name_to_ids,
143            &all_inherits,
144            EdgeKind::Inherits,
145            &mut merged.added_edges,
146        );
147        merged.deferred_throws = resolve_deferred(
148            &name_to_ids,
149            &all_throws,
150            EdgeKind::Throws,
151            &mut merged.added_edges,
152        );
153        merged.deferred_annotated = resolve_deferred(
154            &name_to_ids,
155            &all_annotated,
156            EdgeKind::Annotated,
157            &mut merged.added_edges,
158        );
159
160        for deleted in to_delete {
161            merged.removed_files.push(deleted.path().to_owned());
162        }
163
164        // Synthesise Folder and File structural nodes from the file paths of
165        // all nodes in this diff, then append them to the merged result.
166        let (struct_nodes, struct_edges) = build_structural_nodes(&merged);
167        merged.added_nodes.extend(struct_nodes);
168        merged.added_edges.extend(struct_edges);
169
170        Ok((merged, head_sha))
171    }
172
173    // ── Private helpers ───────────────────────────────────────────────────────
174
175    fn supported_extensions(&self) -> Vec<&'static str> {
176        vec!["rs", "py", "ts", "tsx", "js", "jsx", "mjs", "cjs", "go"]
177    }
178
179    fn index_file(&self, repo_relative_path: &Path) -> FileIndexResult {
180        let abs_path = self.repo_root.join(repo_relative_path);
181        let empty = || {
182            (
183                GraphDiff::default(),
184                Vec::<(NodeId, String)>::new(),
185                Vec::<(NodeId, String)>::new(),
186                Vec::<(NodeId, String)>::new(),
187                Vec::<(NodeId, String)>::new(),
188                Vec::<(NodeId, String)>::new(),
189                Vec::<(NodeId, String)>::new(),
190                Vec::<(NodeId, String)>::new(),
191            )
192        };
193
194        if self.should_ignore(repo_relative_path) {
195            return Ok(empty());
196        }
197
198        let source = fs::read_to_string(&abs_path).map_err(|e| GitCortexError::Parse {
199            file: abs_path.clone(),
200            message: e.to_string(),
201        })?;
202
203        if source.len() > 512 * 1024 {
204            return Ok(empty());
205        }
206
207        let parser = match parser_for_path(repo_relative_path) {
208            Some(p) => p,
209            None => return Ok(empty()),
210        };
211
212        let ParseResult {
213            nodes,
214            edges,
215            deferred_calls,
216            deferred_uses,
217            deferred_implements,
218            deferred_imports,
219            deferred_inherits,
220            deferred_throws,
221            deferred_annotated,
222        } = parser.parse(repo_relative_path, &source)?;
223
224        let mut diff = GraphDiff::default();
225        // Remove old code nodes for this file and old structural nodes for
226        // every ancestor folder (they'll be re-synthesised after merging).
227        diff.removed_files.push(repo_relative_path.to_owned());
228        for ancestor in repo_relative_path.ancestors().skip(1) {
229            if ancestor == Path::new("") || ancestor == Path::new(".") {
230                break;
231            }
232            diff.removed_files.push(ancestor.to_path_buf());
233        }
234        diff.added_nodes = nodes;
235        diff.added_edges = edges;
236
237        Ok((
238            diff,
239            deferred_calls,
240            deferred_uses,
241            deferred_implements,
242            deferred_imports,
243            deferred_inherits,
244            deferred_throws,
245            deferred_annotated,
246        ))
247    }
248
249    fn should_ignore(&self, path: &Path) -> bool {
250        self.ignorer
251            .matched_path_or_any_parents(path, false)
252            .is_ignore()
253    }
254}
255
256// ── Helpers ───────────────────────────────────────────────────────────────────
257
258/// Resolve deferred `(src_id, target_name)` pairs against a diff-local
259/// name→NodeId map. Returns the subset that couldn't be resolved (because the
260/// target lives in an unchanged file not present in this diff). The caller
261/// stores the unresolved entries in `GraphDiff.deferred_*` so the store can
262/// resolve them against its full existing database.
263fn resolve_deferred(
264    name_to_ids: &HashMap<&str, Vec<&NodeId>>,
265    deferred: &[(NodeId, String)],
266    kind: EdgeKind,
267    edges: &mut Vec<Edge>,
268) -> Vec<(NodeId, String)> {
269    let mut unresolved = Vec::new();
270    for (src_id, target_name) in deferred {
271        if let Some(dst_ids) = name_to_ids.get(target_name.as_str()) {
272            for dst_id in dst_ids {
273                let edge = Edge {
274                    src: src_id.clone(),
275                    dst: (*dst_id).clone(),
276                    kind: kind.clone(),
277                };
278                if !edges.contains(&edge) {
279                    edges.push(edge);
280                }
281            }
282        } else {
283            unresolved.push((src_id.clone(), target_name.clone()));
284        }
285    }
286    unresolved
287}
288
289/// Derives `Folder` and `File` structural nodes from the code nodes already in
290/// `diff`. For each unique file path seen in `diff.added_nodes`:
291///   - Creates one `File` node (kind = File, file = the_file_path).
292///   - Creates `Folder` nodes for every ancestor directory component.
293///   - Adds Contains edges: folder→subfolder, folder→file, file→top-level nodes.
294///
295/// Top-level nodes are code nodes that are NOT already the `dst` of a Contains
296/// edge within the same file (i.e. not already contained by a module/struct).
297fn build_structural_nodes(diff: &GraphDiff) -> (Vec<Node>, Vec<Edge>) {
298    use std::collections::HashSet;
299
300    // Which code node IDs already have an incoming Contains edge?
301    let contained: HashSet<&NodeId> = diff
302        .added_edges
303        .iter()
304        .filter(|e| e.kind == EdgeKind::Contains)
305        .map(|e| &e.dst)
306        .collect();
307
308    // Group code nodes by file path.
309    let mut by_file: HashMap<&Path, Vec<&Node>> = HashMap::new();
310    for node in &diff.added_nodes {
311        by_file.entry(node.file.as_path()).or_default().push(node);
312    }
313
314    let mut new_nodes: Vec<Node> = Vec::new();
315    let mut new_edges: Vec<Edge> = Vec::new();
316    // folder path → NodeId (so we can build hierarchy edges)
317    let mut folder_ids: HashMap<PathBuf, NodeId> = HashMap::new();
318
319    // ── File nodes ────────────────────────────────────────────────────────────
320    let mut file_ids: HashMap<&Path, NodeId> = HashMap::new();
321    for (file_path, code_nodes) in &by_file {
322        let file_id = NodeId::new();
323        let name = file_path
324            .file_name()
325            .and_then(|n| n.to_str())
326            .unwrap_or("")
327            .to_owned();
328        new_nodes.push(Node {
329            id: file_id.clone(),
330            name,
331            kind: NodeKind::File,
332            qualified_name: file_path.to_string_lossy().into_owned(),
333            file: file_path.to_path_buf(),
334            span: Span {
335                start_line: 1,
336                end_line: 1,
337            },
338            metadata: NodeMetadata {
339                visibility: Visibility::Pub,
340                ..Default::default()
341            },
342        });
343        file_ids.insert(file_path, file_id.clone());
344
345        // File → top-level code node Contains edges.
346        for node in code_nodes {
347            if !contained.contains(&node.id) {
348                new_edges.push(Edge {
349                    src: file_id.clone(),
350                    dst: node.id.clone(),
351                    kind: EdgeKind::Contains,
352                });
353            }
354        }
355    }
356
357    // ── Folder nodes (collect unique ancestor directories) ────────────────────
358    let unique_dirs: HashSet<PathBuf> = by_file
359        .keys()
360        .flat_map(|p| p.ancestors().skip(1))
361        .filter(|p| *p != Path::new("") && *p != Path::new("."))
362        .map(|p| p.to_path_buf())
363        .collect();
364
365    for dir in &unique_dirs {
366        let dir_id = folder_ids.entry(dir.clone()).or_default().clone();
367        let name = dir
368            .file_name()
369            .and_then(|n| n.to_str())
370            .unwrap_or(dir.to_str().unwrap_or(""))
371            .to_owned();
372        new_nodes.push(Node {
373            id: dir_id,
374            name,
375            kind: NodeKind::Folder,
376            qualified_name: dir.to_string_lossy().into_owned(),
377            file: dir.clone(),
378            span: Span {
379                start_line: 1,
380                end_line: 1,
381            },
382            metadata: NodeMetadata {
383                visibility: Visibility::Pub,
384                ..Default::default()
385            },
386        });
387    }
388
389    // ── Folder→File and Folder→Folder Contains edges ──────────────────────────
390    for (file_path, file_id) in &file_ids {
391        if let Some(parent) = file_path.parent().filter(|p| *p != Path::new("")) {
392            if let Some(dir_id) = folder_ids.get(parent) {
393                new_edges.push(Edge {
394                    src: dir_id.clone(),
395                    dst: file_id.clone(),
396                    kind: EdgeKind::Contains,
397                });
398            }
399        }
400    }
401    for dir in &unique_dirs {
402        let child_id = folder_ids[dir].clone();
403        if let Some(parent) = dir
404            .parent()
405            .filter(|p| *p != Path::new("") && *p != Path::new("."))
406        {
407            if let Some(parent_id) = folder_ids.get(parent) {
408                new_edges.push(Edge {
409                    src: parent_id.clone(),
410                    dst: child_id,
411                    kind: EdgeKind::Contains,
412                });
413            }
414        }
415    }
416
417    (new_nodes, new_edges)
418}
419
420fn build_ignorer(repo_root: &Path) -> Gitignore {
421    let ignore_path = repo_root.join(".gitcortex/ignore");
422    let mut builder = GitignoreBuilder::new(repo_root);
423    if ignore_path.exists() {
424        let _ = builder.add(ignore_path);
425    }
426    builder.build().unwrap_or_else(|_| Gitignore::empty())
427}