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)>;
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, u32)> = 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_calls(
154            &name_to_ids,
155            &id_to_file,
156            &all_calls,
157            &mut merged.added_edges,
158            &mut seen_edges,
159        );
160        merged.deferred_uses = resolve_deferred(
161            &name_to_ids,
162            &id_to_file,
163            &all_uses,
164            EdgeKind::Uses,
165            &mut merged.added_edges,
166            &mut seen_edges,
167        );
168        merged.deferred_implements = resolve_deferred(
169            &name_to_ids,
170            &id_to_file,
171            &all_implements,
172            EdgeKind::Implements,
173            &mut merged.added_edges,
174            &mut seen_edges,
175        );
176        // Imports use placeholder src IDs so we can't resolve them against the store;
177        // resolve what we can locally and silently drop the rest.
178        let _ = resolve_deferred(
179            &name_to_ids,
180            &id_to_file,
181            &all_imports,
182            EdgeKind::Imports,
183            &mut merged.added_edges,
184            &mut seen_edges,
185        );
186        merged.deferred_inherits = resolve_deferred(
187            &name_to_ids,
188            &id_to_file,
189            &all_inherits,
190            EdgeKind::Inherits,
191            &mut merged.added_edges,
192            &mut seen_edges,
193        );
194        merged.deferred_throws = resolve_deferred(
195            &name_to_ids,
196            &id_to_file,
197            &all_throws,
198            EdgeKind::Throws,
199            &mut merged.added_edges,
200            &mut seen_edges,
201        );
202        merged.deferred_annotated = resolve_deferred(
203            &name_to_ids,
204            &id_to_file,
205            &all_annotated,
206            EdgeKind::Annotated,
207            &mut merged.added_edges,
208            &mut seen_edges,
209        );
210        mark!(format!(
211            "resolve_deferred done: {} total edges",
212            merged.added_edges.len()
213        ));
214
215        // Mirror annotation NAMES onto each decorated node's metadata. The
216        // `Annotated` edge above only survives when the decorator is defined
217        // in-repo; most framework decorators (`@app.route`, `@Test`) are
218        // external and dropped. Storing the names as metadata keeps them
219        // queryable regardless.
220        {
221            let mut ann_by_id: HashMap<String, Vec<String>> = HashMap::new();
222            for (node_id, name) in &all_annotated {
223                ann_by_id
224                    .entry(node_id.as_str())
225                    .or_default()
226                    .push(name.clone());
227            }
228            for node in &mut merged.added_nodes {
229                if let Some(names) = ann_by_id.get(&node.id.as_str()) {
230                    let mut seen: HashSet<String> = HashSet::new();
231                    node.metadata.annotations = names
232                        .iter()
233                        .filter(|n| seen.insert((*n).clone()))
234                        .cloned()
235                        .collect();
236                }
237            }
238        }
239
240        for deleted in to_delete {
241            merged.removed_files.push(deleted.path().to_owned());
242        }
243
244        // Synthesise Folder and File structural nodes from the file paths of
245        // all nodes in this diff, then append them to the merged result.
246        let (struct_nodes, struct_edges) = build_structural_nodes(&merged);
247        merged.added_nodes.extend(struct_nodes);
248        merged.added_edges.extend(struct_edges);
249        mark!("structural nodes done");
250
251        Ok((merged, head_sha))
252    }
253
254    // ── Private helpers ───────────────────────────────────────────────────────
255
256    fn supported_extensions(&self) -> Vec<&'static str> {
257        vec![
258            "rs", "py", "ts", "tsx", "js", "jsx", "mjs", "cjs", "go", "java",
259        ]
260    }
261
262    fn index_file(&self, repo_relative_path: &Path) -> FileIndexResult {
263        let abs_path = self.repo_root.join(repo_relative_path);
264        let empty = || {
265            (
266                GraphDiff::default(),
267                Vec::<(NodeId, String, u32)>::new(),
268                Vec::<(NodeId, String)>::new(),
269                Vec::<(NodeId, String)>::new(),
270                Vec::<(NodeId, String)>::new(),
271                Vec::<(NodeId, String)>::new(),
272                Vec::<(NodeId, String)>::new(),
273                Vec::<(NodeId, String)>::new(),
274            )
275        };
276
277        if self.should_ignore(repo_relative_path) {
278            return Ok(empty());
279        }
280
281        let source = fs::read_to_string(&abs_path).map_err(|e| GitCortexError::Parse {
282            file: abs_path.clone(),
283            message: e.to_string(),
284        })?;
285
286        if source.len() > 512 * 1024 {
287            return Ok(empty());
288        }
289
290        let parser = match parser_for_path(repo_relative_path) {
291            Some(p) => p,
292            None => return Ok(empty()),
293        };
294
295        let ParseResult {
296            nodes,
297            edges,
298            deferred_calls,
299            deferred_uses,
300            deferred_implements,
301            deferred_imports,
302            deferred_inherits,
303            deferred_throws,
304            deferred_annotated,
305        } = parser.parse(repo_relative_path, &source)?;
306
307        let mut diff = GraphDiff::default();
308        // Remove old code nodes for this file and old structural nodes for
309        // every ancestor folder (they'll be re-synthesised after merging).
310        diff.removed_files.push(repo_relative_path.to_owned());
311        for ancestor in repo_relative_path.ancestors().skip(1) {
312            if ancestor == Path::new("") || ancestor == Path::new(".") {
313                break;
314            }
315            diff.removed_files.push(ancestor.to_path_buf());
316        }
317        diff.added_nodes = nodes;
318        diff.added_edges = edges;
319
320        Ok((
321            diff,
322            deferred_calls,
323            deferred_uses,
324            deferred_implements,
325            deferred_imports,
326            deferred_inherits,
327            deferred_throws,
328            deferred_annotated,
329        ))
330    }
331
332    fn should_ignore(&self, path: &Path) -> bool {
333        self.ignorer
334            .matched_path_or_any_parents(path, false)
335            .is_ignore()
336    }
337}
338
339// ── Helpers ───────────────────────────────────────────────────────────────────
340
341/// When a name resolves to more than this many same-language definitions, it's
342/// treated as ambiguous and produces NO edges. Hot names like `get`, `save`,
343/// `__init__`, `filter` have hundreds of definitions; linking a call site to
344/// every one of them is both useless (no precision) and the source of an
345/// edge explosion that made full indexing O(call_sites × defs). Skipping the
346/// over-ambiguous names keeps the precise majority and drops the noise.
347const MAX_RESOLVE_FANOUT: usize = 8;
348
349/// Resolve deferred `(src_id, target_name)` pairs against a diff-local
350/// name→NodeId map. Returns the subset that couldn't be resolved (because the
351/// target lives in an unchanged file not present in this diff). The caller
352/// stores the unresolved entries in `GraphDiff.deferred_*` so the store can
353/// resolve them against its full existing database.
354///
355/// Candidate destinations are filtered to the same language family as the
356/// source (by file extension) — otherwise a Java caller's deferred `greet`
357/// would resolve to every `greet` symbol across all languages in the diff.
358///
359/// `seen` dedups edges in O(1); the previous `Vec::contains` made this O(E²)
360/// and dominated full-index time on large repos.
361fn resolve_deferred(
362    name_to_ids: &HashMap<&str, Vec<&NodeId>>,
363    id_to_file: &HashMap<&NodeId, &Path>,
364    deferred: &[(NodeId, String)],
365    kind: EdgeKind,
366    edges: &mut Vec<Edge>,
367    seen: &mut HashSet<(String, String, String)>,
368) -> Vec<(NodeId, String)> {
369    let mut unresolved = Vec::new();
370    for (src_id, target_name) in deferred {
371        let src_exts = id_to_file
372            .get(src_id)
373            .and_then(|p| language_extensions_for_path(p));
374
375        let Some(dst_ids) = name_to_ids.get(target_name.as_str()) else {
376            unresolved.push((src_id.clone(), target_name.clone()));
377            continue;
378        };
379
380        // Same-language candidates only.
381        let candidates: Vec<&NodeId> = dst_ids
382            .iter()
383            .copied()
384            .filter(|dst_id| match (src_exts, id_to_file.get(*dst_id)) {
385                (Some(exts), Some(dst_path)) => language_extensions_for_path(dst_path)
386                    .map(|dst_exts| dst_exts.first() == exts.first())
387                    .unwrap_or(true),
388                _ => true,
389            })
390            .collect();
391
392        // Over-ambiguous name: don't fan out. Treat as resolved (not pushed to
393        // `unresolved`) so the store doesn't retry the same explosive match.
394        if candidates.len() > MAX_RESOLVE_FANOUT {
395            continue;
396        }
397
398        let mut matched_any = false;
399        for dst_id in candidates {
400            matched_any = true;
401            let key = (src_id.as_str(), dst_id.as_str(), kind.to_string());
402            if seen.insert(key) {
403                // Cross-file name resolution — lower confidence than a direct
404                // same-file edge.
405                edges.push(
406                    Edge::new(src_id.clone(), dst_id.clone(), kind.clone())
407                        .with_confidence(EdgeConfidence::Inferred),
408                );
409            }
410        }
411        if !matched_any {
412            unresolved.push((src_id.clone(), target_name.clone()));
413        }
414    }
415    unresolved
416}
417
418/// Like [`resolve_deferred`] but for `Calls` edges, carrying each call's source
419/// line onto the resulting edge. Unresolved calls are returned as
420/// `(caller_id, callee_name, line)` so the store can resolve them later and
421/// still record the line.
422fn resolve_calls(
423    name_to_ids: &HashMap<&str, Vec<&NodeId>>,
424    id_to_file: &HashMap<&NodeId, &Path>,
425    deferred: &[(NodeId, String, u32)],
426    edges: &mut Vec<Edge>,
427    seen: &mut HashSet<(String, String, String)>,
428) -> Vec<(NodeId, String, u32)> {
429    let mut unresolved = Vec::new();
430    for (src_id, target_name, line) in deferred {
431        let src_exts = id_to_file
432            .get(src_id)
433            .and_then(|p| language_extensions_for_path(p));
434
435        let Some(dst_ids) = name_to_ids.get(target_name.as_str()) else {
436            unresolved.push((src_id.clone(), target_name.clone(), *line));
437            continue;
438        };
439
440        let candidates: Vec<&NodeId> = dst_ids
441            .iter()
442            .copied()
443            .filter(|dst_id| match (src_exts, id_to_file.get(*dst_id)) {
444                (Some(exts), Some(dst_path)) => language_extensions_for_path(dst_path)
445                    .map(|dst_exts| dst_exts.first() == exts.first())
446                    .unwrap_or(true),
447                _ => true,
448            })
449            .collect();
450
451        if candidates.len() > MAX_RESOLVE_FANOUT {
452            continue;
453        }
454
455        let mut matched_any = false;
456        for dst_id in candidates {
457            matched_any = true;
458            let key = (
459                src_id.as_str(),
460                dst_id.as_str(),
461                EdgeKind::Calls.to_string(),
462            );
463            if seen.insert(key) {
464                edges.push(
465                    Edge::call(src_id.clone(), dst_id.clone(), *line)
466                        .with_confidence(EdgeConfidence::Inferred),
467                );
468            }
469        }
470        if !matched_any {
471            unresolved.push((src_id.clone(), target_name.clone(), *line));
472        }
473    }
474    unresolved
475}
476
477/// File extension family for `path`. Mirrors the store-side helper — kept
478/// duplicated to avoid pulling kuzu into the indexer.
479fn language_extensions_for_path(path: &Path) -> Option<&'static [&'static str]> {
480    let ext = path.extension()?.to_str()?;
481    match ext {
482        "rs" => Some(&[".rs"]),
483        "py" => Some(&[".py"]),
484        "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => {
485            Some(&[".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"])
486        }
487        "go" => Some(&[".go"]),
488        "java" => Some(&[".java"]),
489        _ => None,
490    }
491}
492
493/// Derives `Folder` and `File` structural nodes from the code nodes already in
494/// `diff`. For each unique file path seen in `diff.added_nodes`:
495///   - Creates one `File` node (kind = File, file = the_file_path).
496///   - Creates `Folder` nodes for every ancestor directory component.
497///   - Adds Contains edges: folder→subfolder, folder→file, file→top-level nodes.
498///
499/// Top-level nodes are code nodes that are NOT already the `dst` of a Contains
500/// edge within the same file (i.e. not already contained by a module/struct).
501fn build_structural_nodes(diff: &GraphDiff) -> (Vec<Node>, Vec<Edge>) {
502    use std::collections::HashSet;
503
504    // Which code node IDs already have an incoming Contains edge?
505    let contained: HashSet<&NodeId> = diff
506        .added_edges
507        .iter()
508        .filter(|e| e.kind == EdgeKind::Contains)
509        .map(|e| &e.dst)
510        .collect();
511
512    // Group code nodes by file path.
513    let mut by_file: HashMap<&Path, Vec<&Node>> = HashMap::new();
514    for node in &diff.added_nodes {
515        by_file.entry(node.file.as_path()).or_default().push(node);
516    }
517
518    let mut new_nodes: Vec<Node> = Vec::new();
519    let mut new_edges: Vec<Edge> = Vec::new();
520    // folder path → NodeId (so we can build hierarchy edges)
521    let mut folder_ids: HashMap<PathBuf, NodeId> = HashMap::new();
522
523    // ── File nodes ────────────────────────────────────────────────────────────
524    let mut file_ids: HashMap<&Path, NodeId> = HashMap::new();
525    for (file_path, code_nodes) in &by_file {
526        let file_id = NodeId::new();
527        let name = file_path
528            .file_name()
529            .and_then(|n| n.to_str())
530            .unwrap_or("")
531            .to_owned();
532        new_nodes.push(Node {
533            id: file_id.clone(),
534            name,
535            kind: NodeKind::File,
536            qualified_name: file_path.to_string_lossy().into_owned(),
537            file: file_path.to_path_buf(),
538            span: Span {
539                start_line: 1,
540                end_line: 1,
541            },
542            metadata: NodeMetadata {
543                visibility: Visibility::Pub,
544                ..Default::default()
545            },
546        });
547        file_ids.insert(file_path, file_id.clone());
548
549        // File → top-level code node Contains edges.
550        for node in code_nodes {
551            if !contained.contains(&node.id) {
552                new_edges.push(Edge {
553                    src: file_id.clone(),
554                    dst: node.id.clone(),
555                    kind: EdgeKind::Contains,
556                    line: None,
557                    confidence: EdgeConfidence::Extracted,
558                });
559            }
560        }
561    }
562
563    // ── Folder nodes (collect unique ancestor directories) ────────────────────
564    let unique_dirs: HashSet<PathBuf> = by_file
565        .keys()
566        .flat_map(|p| p.ancestors().skip(1))
567        .filter(|p| *p != Path::new("") && *p != Path::new("."))
568        .map(|p| p.to_path_buf())
569        .collect();
570
571    for dir in &unique_dirs {
572        let dir_id = folder_ids.entry(dir.clone()).or_default().clone();
573        let name = dir
574            .file_name()
575            .and_then(|n| n.to_str())
576            .unwrap_or(dir.to_str().unwrap_or(""))
577            .to_owned();
578        new_nodes.push(Node {
579            id: dir_id,
580            name,
581            kind: NodeKind::Folder,
582            qualified_name: dir.to_string_lossy().into_owned(),
583            file: dir.clone(),
584            span: Span {
585                start_line: 1,
586                end_line: 1,
587            },
588            metadata: NodeMetadata {
589                visibility: Visibility::Pub,
590                ..Default::default()
591            },
592        });
593    }
594
595    // ── Folder→File and Folder→Folder Contains edges ──────────────────────────
596    for (file_path, file_id) in &file_ids {
597        if let Some(parent) = file_path.parent().filter(|p| *p != Path::new("")) {
598            if let Some(dir_id) = folder_ids.get(parent) {
599                new_edges.push(Edge {
600                    src: dir_id.clone(),
601                    dst: file_id.clone(),
602                    kind: EdgeKind::Contains,
603                    line: None,
604                    confidence: EdgeConfidence::Extracted,
605                });
606            }
607        }
608    }
609    for dir in &unique_dirs {
610        let child_id = folder_ids[dir].clone();
611        if let Some(parent) = dir
612            .parent()
613            .filter(|p| *p != Path::new("") && *p != Path::new("."))
614        {
615            if let Some(parent_id) = folder_ids.get(parent) {
616                new_edges.push(Edge {
617                    src: parent_id.clone(),
618                    dst: child_id,
619                    kind: EdgeKind::Contains,
620                    line: None,
621                    confidence: EdgeConfidence::Extracted,
622                });
623            }
624        }
625    }
626
627    (new_nodes, new_edges)
628}
629
630fn build_ignorer(repo_root: &Path) -> Gitignore {
631    let ignore_path = repo_root.join(".gitcortex/ignore");
632    let mut builder = GitignoreBuilder::new(repo_root);
633    if ignore_path.exists() {
634        let _ = builder.add(ignore_path);
635    }
636    builder.build().unwrap_or_else(|_| Gitignore::empty())
637}