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)>,
23    Vec<(NodeId, String)>,
24    Vec<(NodeId, String)>,
25    Vec<(NodeId, String)>,
26)>;
27
28// ── IncrementalIndexer ────────────────────────────────────────────────────────
29
30/// Orchestrates the differ and parser to produce a `GraphDiff` from
31/// `last_sha..HEAD`. Stateless — callers own the repo root and last SHA.
32pub struct IncrementalIndexer {
33    repo_root: PathBuf,
34    ignorer: Gitignore,
35}
36
37impl IncrementalIndexer {
38    /// Build an indexer rooted at `repo_root`.
39    ///
40    /// Reads `.gitcortex/ignore` (if present) for exclusion patterns.
41    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    /// Run an incremental index from `from_sha` (exclusive) to HEAD.
50    ///
51    /// Returns:
52    /// - `GraphDiff` — changes to apply to the store
53    /// - `String`    — the HEAD SHA to persist as `last_indexed_sha`
54    ///
55    /// When `from_sha` is `None` the entire HEAD tree is indexed (first run).
56    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        // Parse added/modified files in parallel.
76        let per_file: Vec<FileIndexResult> = to_parse
77            .par_iter()
78            .map(|change| self.index_file(change.path()))
79            .collect();
80
81        // Merge diffs and collect all deferred (cross-file) references.
82        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        // Build name → NodeId index from all nodes added in this diff.
97        // Used to resolve all four deferred edge types.
98        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        // Imports use placeholder src IDs so we can't resolve them against the store;
125        // resolve what we can locally and silently drop the rest.
126        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        // Synthesise Folder and File structural nodes from the file paths of
138        // all nodes in this diff, then append them to the merged result.
139        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    // ── Private helpers ───────────────────────────────────────────────────────
147
148    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        // Remove old code nodes for this file and old structural nodes for
193        // every ancestor folder (they'll be re-synthesised after merging).
194        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
220// ── Helpers ───────────────────────────────────────────────────────────────────
221
222/// Resolve deferred `(src_id, target_name)` pairs against a diff-local
223/// name→NodeId map. Returns the subset that couldn't be resolved (because the
224/// target lives in an unchanged file not present in this diff). The caller
225/// stores the unresolved entries in `GraphDiff.deferred_*` so the store can
226/// resolve them against its full existing database.
227fn 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
253/// Derives `Folder` and `File` structural nodes from the code nodes already in
254/// `diff`. For each unique file path seen in `diff.added_nodes`:
255///   - Creates one `File` node (kind = File, file = the_file_path).
256///   - Creates `Folder` nodes for every ancestor directory component.
257///   - Adds Contains edges: folder→subfolder, folder→file, file→top-level nodes.
258///
259/// Top-level nodes are code nodes that are NOT already the `dst` of a Contains
260/// edge within the same file (i.e. not already contained by a module/struct).
261fn build_structural_nodes(diff: &GraphDiff) -> (Vec<Node>, Vec<Edge>) {
262    use std::collections::HashSet;
263
264    // Which code node IDs already have an incoming Contains edge?
265    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    // Group code nodes by file path.
273    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    // folder path → NodeId (so we can build hierarchy edges)
281    let mut folder_ids: HashMap<PathBuf, NodeId> = HashMap::new();
282
283    // ── File nodes ────────────────────────────────────────────────────────────
284    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        // File → top-level code node Contains edges.
310        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    // ── Folder nodes (collect unique ancestor directories) ────────────────────
322    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    // ── Folder→File and Folder→Folder Contains edges ──────────────────────────
354    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}