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::{EdgeConfidence, 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, u32)>, // 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    Vec<(NodeId, String)>,      // deferred_doc_refs
30)>;
31
32// ── IncrementalIndexer ────────────────────────────────────────────────────────
33
34/// Orchestrates the differ and parser to produce a `GraphDiff` from
35/// `last_sha..HEAD`. Stateless — callers own the repo root and last SHA.
36pub struct IncrementalIndexer {
37    repo_root: PathBuf,
38    ignorer: Gitignore,
39}
40
41impl IncrementalIndexer {
42    /// Build an indexer rooted at `repo_root`.
43    ///
44    /// Reads `.gitcortex/ignore` (if present) for exclusion patterns.
45    pub fn new(repo_root: &Path) -> Result<Self> {
46        let ignorer = build_ignorer(repo_root);
47        Ok(Self {
48            repo_root: repo_root.to_owned(),
49            ignorer,
50        })
51    }
52
53    /// Run an incremental index from `from_sha` (exclusive) to HEAD.
54    ///
55    /// Returns:
56    /// - `GraphDiff` — changes to apply to the store
57    /// - `String`    — the HEAD SHA to persist as `last_indexed_sha`
58    ///
59    /// When `from_sha` is `None` the entire HEAD tree is indexed (first run).
60    pub fn run(&self, from_sha: Option<&str>) -> Result<(GraphDiff, String)> {
61        let differ = Differ::open(&self.repo_root)?;
62        let head_sha = differ.head_sha()?;
63
64        if from_sha.map(|s| s == head_sha).unwrap_or(false) {
65            return Ok((GraphDiff::default(), head_sha));
66        }
67
68        let timing = std::env::var_os("GCX_TIMING").is_some();
69        let t0 = std::time::Instant::now();
70        macro_rules! mark {
71            ($label:expr) => {
72                if timing {
73                    eprintln!("[gcx-timing] {:>8.2?}  {}", t0.elapsed(), $label);
74                }
75            };
76        }
77
78        let supported = self.supported_extensions();
79        let changes = differ.changed_files(from_sha, &supported)?;
80
81        if changes.is_empty() {
82            return Ok((GraphDiff::default(), head_sha));
83        }
84
85        let (to_parse, to_delete): (Vec<_>, Vec<_>) = changes
86            .into_iter()
87            .partition(|c| !matches!(c, FileChange::Deleted(_)));
88        mark!(format!("diff: {} files to parse", to_parse.len()));
89
90        // Parse added/modified files in parallel.
91        let per_file: Vec<FileIndexResult> = to_parse
92            .par_iter()
93            .map(|change| self.index_file(change.path()))
94            .collect();
95        mark!("parse (parallel) done");
96
97        // Merge diffs and collect all deferred (cross-file) references.
98        let mut merged = GraphDiff::default();
99        let mut all_calls: Vec<(NodeId, String, u32)> = Vec::new();
100        let mut all_uses: Vec<(NodeId, String)> = Vec::new();
101        let mut all_implements: Vec<(NodeId, String)> = Vec::new();
102        let mut all_imports: Vec<(NodeId, String)> = Vec::new();
103        let mut all_inherits: Vec<(NodeId, String)> = Vec::new();
104        let mut all_throws: Vec<(NodeId, String)> = Vec::new();
105        let mut all_annotated: Vec<(NodeId, String)> = Vec::new();
106        let mut all_doc_refs: Vec<(NodeId, String)> = Vec::new();
107        for result in per_file {
108            let (diff, calls, uses, implements, imports, inherits, throws, annotated, doc_refs) =
109                result?;
110            merged.merge(diff);
111            all_calls.extend(calls);
112            all_uses.extend(uses);
113            all_implements.extend(implements);
114            all_imports.extend(imports);
115            all_inherits.extend(inherits);
116            all_throws.extend(throws);
117            all_annotated.extend(annotated);
118            all_doc_refs.extend(doc_refs);
119        }
120        mark!(format!(
121            "merge done: {} nodes, {} direct edges, {} deferred calls",
122            merged.added_nodes.len(),
123            merged.added_edges.len(),
124            all_calls.len()
125        ));
126
127        // Build name → NodeId index from all nodes added in this diff.
128        // Used to resolve all four deferred edge types.
129        let name_to_ids: HashMap<&str, Vec<&NodeId>> = {
130            let mut map: HashMap<&str, Vec<&NodeId>> = HashMap::new();
131            for node in &merged.added_nodes {
132                map.entry(node.name.as_str()).or_default().push(&node.id);
133            }
134            map
135        };
136
137        // Parallel id → file map. Used to constrain deferred resolution to the
138        // caller's language family — without this, a Java caller's deferred
139        // `greet` would be resolved to all `greet` symbols across every
140        // language in the diff, producing spurious cross-language edges.
141        let id_to_file: HashMap<&NodeId, &Path> = merged
142            .added_nodes
143            .iter()
144            .map(|n| (&n.id, n.file.as_path()))
145            .collect();
146
147        // Edge dedup set, seeded from the direct edges the parsers already
148        // emitted. `resolve_deferred` consults this instead of scanning the
149        // `added_edges` Vec — the old `Vec::contains` made resolution O(E²)
150        // and dominated full-index time on large repos.
151        let mut seen_edges: HashSet<(String, String, String)> = merged
152            .added_edges
153            .iter()
154            .map(|e| (e.src.as_str(), e.dst.as_str(), e.kind.to_string()))
155            .collect();
156
157        merged.deferred_calls = resolve_calls(
158            &name_to_ids,
159            &id_to_file,
160            &all_calls,
161            &mut merged.added_edges,
162            &mut seen_edges,
163        );
164        merged.deferred_uses = resolve_deferred(
165            &name_to_ids,
166            &id_to_file,
167            &all_uses,
168            EdgeKind::Uses,
169            &mut merged.added_edges,
170            &mut seen_edges,
171        );
172        merged.deferred_implements = resolve_deferred(
173            &name_to_ids,
174            &id_to_file,
175            &all_implements,
176            EdgeKind::Implements,
177            &mut merged.added_edges,
178            &mut seen_edges,
179        );
180        // Imports use placeholder src IDs so we can't resolve them against the store;
181        // resolve what we can locally and silently drop the rest.
182        let _ = resolve_deferred(
183            &name_to_ids,
184            &id_to_file,
185            &all_imports,
186            EdgeKind::Imports,
187            &mut merged.added_edges,
188            &mut seen_edges,
189        );
190        merged.deferred_inherits = resolve_deferred(
191            &name_to_ids,
192            &id_to_file,
193            &all_inherits,
194            EdgeKind::Inherits,
195            &mut merged.added_edges,
196            &mut seen_edges,
197        );
198        merged.deferred_throws = resolve_deferred(
199            &name_to_ids,
200            &id_to_file,
201            &all_throws,
202            EdgeKind::Throws,
203            &mut merged.added_edges,
204            &mut seen_edges,
205        );
206        merged.deferred_annotated = resolve_deferred(
207            &name_to_ids,
208            &id_to_file,
209            &all_annotated,
210            EdgeKind::Annotated,
211            &mut merged.added_edges,
212            &mut seen_edges,
213        );
214        // Doc references are inherently cross-language (a Markdown file has
215        // no `language_extensions_for_path` match, so `resolve_deferred`'s
216        // language-scope filter is naturally a no-op here — no fan-out cap
217        // bypass needed, the existing MAX_RESOLVE_FANOUT guard still applies).
218        merged.deferred_doc_refs = resolve_deferred(
219            &name_to_ids,
220            &id_to_file,
221            &all_doc_refs,
222            EdgeKind::References,
223            &mut merged.added_edges,
224            &mut seen_edges,
225        );
226        mark!(format!(
227            "resolve_deferred done: {} total edges",
228            merged.added_edges.len()
229        ));
230
231        // Mirror annotation NAMES onto each decorated node's metadata. The
232        // `Annotated` edge above only survives when the decorator is defined
233        // in-repo; most framework decorators (`@app.route`, `@Test`) are
234        // external and dropped. Storing the names as metadata keeps them
235        // queryable regardless.
236        {
237            let mut ann_by_id: HashMap<String, Vec<String>> = HashMap::new();
238            for (node_id, name) in &all_annotated {
239                ann_by_id
240                    .entry(node_id.as_str())
241                    .or_default()
242                    .push(name.clone());
243            }
244            for node in &mut merged.added_nodes {
245                if let Some(names) = ann_by_id.get(&node.id.as_str()) {
246                    let mut seen: HashSet<String> = HashSet::new();
247                    node.metadata.annotations = names
248                        .iter()
249                        .filter(|n| seen.insert((*n).clone()))
250                        .cloned()
251                        .collect();
252                }
253            }
254        }
255
256        for deleted in to_delete {
257            merged.removed_files.push(deleted.path().to_owned());
258        }
259
260        // Synthesise Folder and File structural nodes from the file paths of
261        // all nodes in this diff, then append them to the merged result.
262        let (struct_nodes, struct_edges) = build_structural_nodes(&merged);
263        merged.added_nodes.extend(struct_nodes);
264        merged.added_edges.extend(struct_edges);
265        mark!("structural nodes done");
266
267        Ok((merged, head_sha))
268    }
269
270    // ── Private helpers ───────────────────────────────────────────────────────
271
272    fn supported_extensions(&self) -> Vec<&'static str> {
273        vec![
274            "rs", "py", "ts", "tsx", "js", "jsx", "mjs", "cjs", "go", "java", "md", "markdown",
275        ]
276    }
277
278    fn index_file(&self, repo_relative_path: &Path) -> FileIndexResult {
279        let abs_path = self.repo_root.join(repo_relative_path);
280        let empty = || {
281            (
282                GraphDiff::default(),
283                Vec::<(NodeId, String, u32)>::new(),
284                Vec::<(NodeId, String)>::new(),
285                Vec::<(NodeId, String)>::new(),
286                Vec::<(NodeId, String)>::new(),
287                Vec::<(NodeId, String)>::new(),
288                Vec::<(NodeId, String)>::new(),
289                Vec::<(NodeId, String)>::new(),
290                Vec::<(NodeId, String)>::new(),
291            )
292        };
293
294        if self.should_ignore(repo_relative_path) {
295            return Ok(empty());
296        }
297
298        let source = fs::read_to_string(&abs_path).map_err(|e| GitCortexError::Parse {
299            file: abs_path.clone(),
300            message: e.to_string(),
301        })?;
302
303        if source.len() > 512 * 1024 {
304            return Ok(empty());
305        }
306
307        let parser = match parser_for_path(repo_relative_path) {
308            Some(p) => p,
309            None => return Ok(empty()),
310        };
311
312        let ParseResult {
313            nodes,
314            edges,
315            deferred_calls,
316            deferred_uses,
317            deferred_implements,
318            deferred_imports,
319            deferred_inherits,
320            deferred_throws,
321            deferred_annotated,
322            deferred_doc_refs,
323        } = parser.parse(repo_relative_path, &source)?;
324
325        let mut diff = GraphDiff::default();
326        // Remove old code nodes for this file and old structural nodes for
327        // every ancestor folder (they'll be re-synthesised after merging).
328        diff.removed_files.push(repo_relative_path.to_owned());
329        for ancestor in repo_relative_path.ancestors().skip(1) {
330            if ancestor == Path::new("") || ancestor == Path::new(".") {
331                break;
332            }
333            diff.removed_files.push(ancestor.to_path_buf());
334        }
335        diff.added_nodes = nodes;
336        diff.added_edges = edges;
337
338        Ok((
339            diff,
340            deferred_calls,
341            deferred_uses,
342            deferred_implements,
343            deferred_imports,
344            deferred_inherits,
345            deferred_throws,
346            deferred_annotated,
347            deferred_doc_refs,
348        ))
349    }
350
351    fn should_ignore(&self, path: &Path) -> bool {
352        self.ignorer
353            .matched_path_or_any_parents(path, false)
354            .is_ignore()
355    }
356}
357
358// ── Helpers ───────────────────────────────────────────────────────────────────
359
360/// When a name resolves to more than this many same-language definitions, it's
361/// treated as ambiguous and produces NO edges. Hot names like `get`, `save`,
362/// `__init__`, `filter` have hundreds of definitions; linking a call site to
363/// every one of them is both useless (no precision) and the source of an
364/// edge explosion that made full indexing O(call_sites × defs). Skipping the
365/// over-ambiguous names keeps the precise majority and drops the noise.
366const MAX_RESOLVE_FANOUT: usize = 8;
367
368/// Resolve deferred `(src_id, target_name)` pairs against a diff-local
369/// name→NodeId map. Returns the subset that couldn't be resolved (because the
370/// target lives in an unchanged file not present in this diff). The caller
371/// stores the unresolved entries in `GraphDiff.deferred_*` so the store can
372/// resolve them against its full existing database.
373///
374/// Candidate destinations are filtered to the same language family as the
375/// source (by file extension) — otherwise a Java caller's deferred `greet`
376/// would resolve to every `greet` symbol across all languages in the diff.
377///
378/// `seen` dedups edges in O(1); the previous `Vec::contains` made this O(E²)
379/// and dominated full-index time on large repos.
380fn resolve_deferred(
381    name_to_ids: &HashMap<&str, Vec<&NodeId>>,
382    id_to_file: &HashMap<&NodeId, &Path>,
383    deferred: &[(NodeId, String)],
384    kind: EdgeKind,
385    edges: &mut Vec<Edge>,
386    seen: &mut HashSet<(String, String, String)>,
387) -> Vec<(NodeId, String)> {
388    let mut unresolved = Vec::new();
389    for (src_id, target_name) in deferred {
390        let src_exts = id_to_file
391            .get(src_id)
392            .and_then(|p| language_extensions_for_path(p));
393
394        let Some(dst_ids) = name_to_ids.get(target_name.as_str()) else {
395            unresolved.push((src_id.clone(), target_name.clone()));
396            continue;
397        };
398
399        // Same-language candidates only.
400        let candidates: Vec<&NodeId> = dst_ids
401            .iter()
402            .copied()
403            .filter(|dst_id| match (src_exts, id_to_file.get(*dst_id)) {
404                (Some(exts), Some(dst_path)) => language_extensions_for_path(dst_path)
405                    .map(|dst_exts| dst_exts.first() == exts.first())
406                    .unwrap_or(true),
407                _ => true,
408            })
409            .collect();
410
411        // Over-ambiguous name: don't fan out. Treat as resolved (not pushed to
412        // `unresolved`) so the store doesn't retry the same explosive match.
413        if candidates.len() > MAX_RESOLVE_FANOUT {
414            continue;
415        }
416
417        let mut matched_any = false;
418        for dst_id in candidates {
419            matched_any = true;
420            let key = (src_id.as_str(), dst_id.as_str(), kind.to_string());
421            if seen.insert(key) {
422                // Cross-file name resolution — lower confidence than a direct
423                // same-file edge.
424                edges.push(
425                    Edge::new(src_id.clone(), dst_id.clone(), kind.clone())
426                        .with_confidence(EdgeConfidence::Inferred),
427                );
428            }
429        }
430        if !matched_any {
431            unresolved.push((src_id.clone(), target_name.clone()));
432        }
433    }
434    unresolved
435}
436
437/// Like [`resolve_deferred`] but for `Calls` edges, carrying each call's source
438/// line onto the resulting edge. Unresolved calls are returned as
439/// `(caller_id, callee_name, line)` so the store can resolve them later and
440/// still record the line.
441fn resolve_calls(
442    name_to_ids: &HashMap<&str, Vec<&NodeId>>,
443    id_to_file: &HashMap<&NodeId, &Path>,
444    deferred: &[(NodeId, String, u32)],
445    edges: &mut Vec<Edge>,
446    seen: &mut HashSet<(String, String, String)>,
447) -> Vec<(NodeId, String, u32)> {
448    let mut unresolved = Vec::new();
449    for (src_id, target_name, line) in deferred {
450        let src_exts = id_to_file
451            .get(src_id)
452            .and_then(|p| language_extensions_for_path(p));
453
454        let Some(dst_ids) = name_to_ids.get(target_name.as_str()) else {
455            unresolved.push((src_id.clone(), target_name.clone(), *line));
456            continue;
457        };
458
459        let candidates: Vec<&NodeId> = dst_ids
460            .iter()
461            .copied()
462            .filter(|dst_id| match (src_exts, id_to_file.get(*dst_id)) {
463                (Some(exts), Some(dst_path)) => language_extensions_for_path(dst_path)
464                    .map(|dst_exts| dst_exts.first() == exts.first())
465                    .unwrap_or(true),
466                _ => true,
467            })
468            .collect();
469
470        if candidates.len() > MAX_RESOLVE_FANOUT {
471            continue;
472        }
473
474        let mut matched_any = false;
475        for dst_id in candidates {
476            matched_any = true;
477            let key = (
478                src_id.as_str(),
479                dst_id.as_str(),
480                EdgeKind::Calls.to_string(),
481            );
482            if seen.insert(key) {
483                edges.push(
484                    Edge::call(src_id.clone(), dst_id.clone(), *line)
485                        .with_confidence(EdgeConfidence::Inferred),
486                );
487            }
488        }
489        if !matched_any {
490            unresolved.push((src_id.clone(), target_name.clone(), *line));
491        }
492    }
493    unresolved
494}
495
496/// File extension family for `path`. Mirrors the store-side helper — kept
497/// duplicated to avoid pulling kuzu into the indexer.
498fn language_extensions_for_path(path: &Path) -> Option<&'static [&'static str]> {
499    let ext = path.extension()?.to_str()?;
500    match ext {
501        "rs" => Some(&[".rs"]),
502        "py" => Some(&[".py"]),
503        "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
504            Some(&[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"])
505        }
506        "go" => Some(&[".go"]),
507        "java" => Some(&[".java"]),
508        _ => None,
509    }
510}
511
512/// Derives `Folder` and `File` structural nodes from the code nodes already in
513/// `diff`. For each unique file path seen in `diff.added_nodes`:
514///   - Creates one `File` node (kind = File, file = the_file_path).
515///   - Creates `Folder` nodes for every ancestor directory component.
516///   - Adds Contains edges: folder→subfolder, folder→file, file→top-level nodes.
517///
518/// Top-level nodes are code nodes that are NOT already the `dst` of a Contains
519/// edge within the same file (i.e. not already contained by a module/struct).
520fn build_structural_nodes(diff: &GraphDiff) -> (Vec<Node>, Vec<Edge>) {
521    use std::collections::HashSet;
522
523    // Which code node IDs already have an incoming Contains edge?
524    let contained: HashSet<&NodeId> = diff
525        .added_edges
526        .iter()
527        .filter(|e| e.kind == EdgeKind::Contains)
528        .map(|e| &e.dst)
529        .collect();
530
531    // Group code nodes by file path.
532    let mut by_file: HashMap<&Path, Vec<&Node>> = HashMap::new();
533    for node in &diff.added_nodes {
534        by_file.entry(node.file.as_path()).or_default().push(node);
535    }
536
537    let mut new_nodes: Vec<Node> = Vec::new();
538    let mut new_edges: Vec<Edge> = Vec::new();
539    // folder path → NodeId (so we can build hierarchy edges)
540    let mut folder_ids: HashMap<PathBuf, NodeId> = HashMap::new();
541
542    // ── File nodes ────────────────────────────────────────────────────────────
543    let mut file_ids: HashMap<&Path, NodeId> = HashMap::new();
544    for (file_path, code_nodes) in &by_file {
545        let file_id = NodeId::new();
546        let name = file_path
547            .file_name()
548            .and_then(|n| n.to_str())
549            .unwrap_or("")
550            .to_owned();
551        new_nodes.push(Node {
552            id: file_id.clone(),
553            name,
554            kind: NodeKind::File,
555            qualified_name: file_path.to_string_lossy().into_owned(),
556            file: file_path.to_path_buf(),
557            span: Span {
558                start_line: 1,
559                end_line: 1,
560            },
561            metadata: NodeMetadata {
562                visibility: Visibility::Pub,
563                ..Default::default()
564            },
565        });
566        file_ids.insert(file_path, file_id.clone());
567
568        // File → top-level code node Contains edges.
569        for node in code_nodes {
570            if !contained.contains(&node.id) {
571                new_edges.push(Edge {
572                    src: file_id.clone(),
573                    dst: node.id.clone(),
574                    kind: EdgeKind::Contains,
575                    line: None,
576                    confidence: EdgeConfidence::Extracted,
577                });
578            }
579        }
580    }
581
582    // ── Folder nodes (collect unique ancestor directories) ────────────────────
583    let unique_dirs: HashSet<PathBuf> = by_file
584        .keys()
585        .flat_map(|p| p.ancestors().skip(1))
586        .filter(|p| *p != Path::new("") && *p != Path::new("."))
587        .map(|p| p.to_path_buf())
588        .collect();
589
590    for dir in &unique_dirs {
591        let dir_id = folder_ids.entry(dir.clone()).or_default().clone();
592        let name = dir
593            .file_name()
594            .and_then(|n| n.to_str())
595            .unwrap_or(dir.to_str().unwrap_or(""))
596            .to_owned();
597        new_nodes.push(Node {
598            id: dir_id,
599            name,
600            kind: NodeKind::Folder,
601            qualified_name: dir.to_string_lossy().into_owned(),
602            file: dir.clone(),
603            span: Span {
604                start_line: 1,
605                end_line: 1,
606            },
607            metadata: NodeMetadata {
608                visibility: Visibility::Pub,
609                ..Default::default()
610            },
611        });
612    }
613
614    // ── Folder→File and Folder→Folder Contains edges ──────────────────────────
615    for (file_path, file_id) in &file_ids {
616        if let Some(parent) = file_path.parent().filter(|p| *p != Path::new("")) {
617            if let Some(dir_id) = folder_ids.get(parent) {
618                new_edges.push(Edge {
619                    src: dir_id.clone(),
620                    dst: file_id.clone(),
621                    kind: EdgeKind::Contains,
622                    line: None,
623                    confidence: EdgeConfidence::Extracted,
624                });
625            }
626        }
627    }
628    for dir in &unique_dirs {
629        let child_id = folder_ids[dir].clone();
630        if let Some(parent) = dir
631            .parent()
632            .filter(|p| *p != Path::new("") && *p != Path::new("."))
633        {
634            if let Some(parent_id) = folder_ids.get(parent) {
635                new_edges.push(Edge {
636                    src: parent_id.clone(),
637                    dst: child_id,
638                    kind: EdgeKind::Contains,
639                    line: None,
640                    confidence: EdgeConfidence::Extracted,
641                });
642            }
643        }
644    }
645
646    (new_nodes, new_edges)
647}
648
649fn build_ignorer(repo_root: &Path) -> Gitignore {
650    let ignore_path = repo_root.join(".gitcortex/ignore");
651    let mut builder = GitignoreBuilder::new(repo_root);
652    if ignore_path.exists() {
653        let _ = builder.add(ignore_path);
654    }
655    builder.build().unwrap_or_else(|_| Gitignore::empty())
656}