Skip to main content

gitcortex_indexer/
indexer.rs

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)>, // 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 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        // Parse added/modified files in parallel.
90        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        // Merge diffs and collect all deferred (cross-file) references.
97        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        // Build name → NodeId index from all nodes added in this diff.
124        // Used to resolve all four deferred edge types.
125        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        // Parallel id → file map. Used to constrain deferred resolution to the
134        // caller's language family — without this, a Java caller's deferred
135        // `greet` would be resolved to all `greet` symbols across every
136        // language in the diff, producing spurious cross-language edges.
137        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        // Edge dedup set, seeded from the direct edges the parsers already
144        // emitted. `resolve_deferred` consults this instead of scanning the
145        // `added_edges` Vec — the old `Vec::contains` made resolution O(E²)
146        // and dominated full-index time on large repos.
147        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        // Imports use placeholder src IDs so we can't resolve them against the store;
178        // resolve what we can locally and silently drop the rest.
179        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        // Synthesise Folder and File structural nodes from the file paths of
221        // all nodes in this diff, then append them to the merged result.
222        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    // ── Private helpers ───────────────────────────────────────────────────────
231
232    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        // Remove old code nodes for this file and old structural nodes for
285        // every ancestor folder (they'll be re-synthesised after merging).
286        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
315// ── Helpers ───────────────────────────────────────────────────────────────────
316
317/// When a name resolves to more than this many same-language definitions, it's
318/// treated as ambiguous and produces NO edges. Hot names like `get`, `save`,
319/// `__init__`, `filter` have hundreds of definitions; linking a call site to
320/// every one of them is both useless (no precision) and the source of an
321/// edge explosion that made full indexing O(call_sites × defs). Skipping the
322/// over-ambiguous names keeps the precise majority and drops the noise.
323const MAX_RESOLVE_FANOUT: usize = 8;
324
325/// Resolve deferred `(src_id, target_name)` pairs against a diff-local
326/// name→NodeId map. Returns the subset that couldn't be resolved (because the
327/// target lives in an unchanged file not present in this diff). The caller
328/// stores the unresolved entries in `GraphDiff.deferred_*` so the store can
329/// resolve them against its full existing database.
330///
331/// Candidate destinations are filtered to the same language family as the
332/// source (by file extension) — otherwise a Java caller's deferred `greet`
333/// would resolve to every `greet` symbol across all languages in the diff.
334///
335/// `seen` dedups edges in O(1); the previous `Vec::contains` made this O(E²)
336/// and dominated full-index time on large repos.
337fn 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        // Same-language candidates only.
357        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        // Over-ambiguous name: don't fan out. Treat as resolved (not pushed to
369        // `unresolved`) so the store doesn't retry the same explosive match.
370        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
393/// File extension family for `path`. Mirrors the store-side helper — kept
394/// duplicated to avoid pulling kuzu into the indexer.
395fn 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
409/// Derives `Folder` and `File` structural nodes from the code nodes already in
410/// `diff`. For each unique file path seen in `diff.added_nodes`:
411///   - Creates one `File` node (kind = File, file = the_file_path).
412///   - Creates `Folder` nodes for every ancestor directory component.
413///   - Adds Contains edges: folder→subfolder, folder→file, file→top-level nodes.
414///
415/// Top-level nodes are code nodes that are NOT already the `dst` of a Contains
416/// edge within the same file (i.e. not already contained by a module/struct).
417fn build_structural_nodes(diff: &GraphDiff) -> (Vec<Node>, Vec<Edge>) {
418    use std::collections::HashSet;
419
420    // Which code node IDs already have an incoming Contains edge?
421    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    // Group code nodes by file path.
429    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    // folder path → NodeId (so we can build hierarchy edges)
437    let mut folder_ids: HashMap<PathBuf, NodeId> = HashMap::new();
438
439    // ── File nodes ────────────────────────────────────────────────────────────
440    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        // File → top-level code node Contains edges.
466        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    // ── Folder nodes (collect unique ancestor directories) ────────────────────
478    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    // ── Folder→File and Folder→Folder Contains edges ──────────────────────────
510    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}