Skip to main content

lean_ctx/core/repomap/
graph.rs

1//! Graph builder for repo map.
2//!
3//! Constructs a file-level directed graph from the project index edges
4//! and call graph edges, then exposes symbol definitions per file.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::core::call_graph::CallGraph;
9use crate::core::graph_index::{self, ProjectIndex, SymbolEntry};
10
11/// A symbol definition with its file context.
12#[derive(Debug, Clone)]
13pub struct SymbolDef {
14    pub name: String,
15    pub kind: String,
16    pub file: String,
17    pub line: usize,
18    pub end_line: usize,
19    pub is_exported: bool,
20    pub signature: String,
21}
22
23/// File-level graph combining import edges and call edges.
24pub struct RepoGraph {
25    pub files: HashSet<String>,
26    /// Forward adjacency: file -> list of files it depends on.
27    pub forward: HashMap<String, Vec<String>>,
28    /// All symbol definitions grouped by file.
29    pub symbols_by_file: HashMap<String, Vec<SymbolDef>>,
30}
31
32impl RepoGraph {
33    /// Build the repo graph from a project root.
34    ///
35    /// Loads or builds the project index and call graph,
36    /// then merges their edges into a unified file-level graph.
37    pub fn build(project_root: &str) -> Self {
38        let (index, content_cache) = graph_index::scan_with_content_cache(project_root);
39        let call_graph = CallGraph::load_or_build(project_root, &index);
40
41        Self::from_index_and_calls(&index, &call_graph, &content_cache)
42    }
43
44    fn from_index_and_calls(
45        index: &ProjectIndex,
46        call_graph: &CallGraph,
47        content_cache: &HashMap<String, String>,
48    ) -> Self {
49        let files: HashSet<String> = index.files.keys().cloned().collect();
50
51        let mut forward: HashMap<String, Vec<String>> = HashMap::new();
52
53        // Import edges from the project index
54        for edge in &index.edges {
55            if files.contains(&edge.from) && files.contains(&edge.to) && edge.from != edge.to {
56                forward
57                    .entry(edge.from.clone())
58                    .or_default()
59                    .push(edge.to.clone());
60            }
61        }
62
63        // Call edges from the call graph
64        let symbols_by_name = build_symbol_location_map(index);
65        for call_edge in &call_graph.edges {
66            if let Some(target_file) = symbols_by_name.get(&call_edge.callee_name.to_lowercase()) {
67                if files.contains(&call_edge.caller_file)
68                    && files.contains(target_file)
69                    && call_edge.caller_file != *target_file
70                {
71                    forward
72                        .entry(call_edge.caller_file.clone())
73                        .or_default()
74                        .push(target_file.clone());
75                }
76            }
77        }
78
79        // Deduplicate edges
80        for deps in forward.values_mut() {
81            deps.sort();
82            deps.dedup();
83        }
84
85        let symbols_by_file = build_symbols_with_signatures(index, content_cache);
86
87        Self {
88            files,
89            forward,
90            symbols_by_file,
91        }
92    }
93}
94
95/// Map lowercase symbol name -> file path (first definition wins).
96fn build_symbol_location_map(index: &ProjectIndex) -> HashMap<String, String> {
97    let mut map: HashMap<String, String> = HashMap::with_capacity(index.symbols.len());
98    for sym in index.symbols.values() {
99        map.entry(sym.name.to_lowercase())
100            .or_insert_with(|| sym.file.clone());
101    }
102    map
103}
104
105/// Build symbol definitions with compact signatures from file contents.
106fn build_symbols_with_signatures(
107    index: &ProjectIndex,
108    content_cache: &HashMap<String, String>,
109) -> HashMap<String, Vec<SymbolDef>> {
110    let mut result: HashMap<String, Vec<SymbolDef>> = HashMap::new();
111
112    // Group index symbols by file
113    let mut idx_symbols: HashMap<&str, Vec<&SymbolEntry>> = HashMap::new();
114    for sym in index.symbols.values() {
115        idx_symbols.entry(sym.file.as_str()).or_default().push(sym);
116    }
117
118    for (file_path, file_entry) in &index.files {
119        let ext = std::path::Path::new(file_path)
120            .extension()
121            .and_then(|e| e.to_str())
122            .unwrap_or("");
123
124        // Extract signatures from file content if available
125        let signatures = content_cache
126            .get(file_path)
127            .map(|content| crate::core::signatures::extract_signatures(content, ext))
128            .unwrap_or_default();
129
130        let sig_by_name: HashMap<&str, &crate::core::signatures::Signature> =
131            signatures.iter().map(|s| (s.name.as_str(), s)).collect();
132
133        let mut file_symbols: Vec<SymbolDef> = Vec::new();
134
135        if let Some(syms) = idx_symbols.get(file_path.as_str()) {
136            for sym in syms {
137                let signature = sig_by_name
138                    .get(sym.name.as_str())
139                    .map_or_else(|| format!("{} {}", sym.kind, sym.name), |s| s.to_compact());
140
141                file_symbols.push(SymbolDef {
142                    name: sym.name.clone(),
143                    kind: sym.kind.clone(),
144                    file: sym.file.clone(),
145                    line: sym.start_line,
146                    end_line: sym.end_line,
147                    is_exported: sym.is_exported,
148                    signature,
149                });
150            }
151        }
152
153        // Also include exports from file entry that may not be in the symbols map
154        for export in &file_entry.exports {
155            let already_present = file_symbols.iter().any(|s| s.name == *export);
156            if !already_present {
157                let signature = sig_by_name
158                    .get(export.as_str())
159                    .map_or_else(|| export.clone(), |s| s.to_compact());
160
161                let (line, end_line) = sig_by_name
162                    .get(export.as_str())
163                    .and_then(|s| s.start_line.zip(s.end_line))
164                    .unwrap_or((0, 0));
165
166                file_symbols.push(SymbolDef {
167                    name: export.clone(),
168                    kind: "export".to_string(),
169                    file: file_path.clone(),
170                    line,
171                    end_line,
172                    is_exported: true,
173                    signature,
174                });
175            }
176        }
177
178        file_symbols.sort_by_key(|s| s.line);
179
180        if !file_symbols.is_empty() {
181            result.insert(file_path.clone(), file_symbols);
182        }
183    }
184
185    result
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn symbol_location_map_uses_first_definition() {
194        let mut index = ProjectIndex::new("/tmp");
195        index.symbols.insert(
196            "a::foo".into(),
197            SymbolEntry {
198                file: "a.rs".into(),
199                name: "foo".into(),
200                kind: "fn".into(),
201                start_line: 1,
202                end_line: 10,
203                is_exported: true,
204            },
205        );
206        index.symbols.insert(
207            "b::foo".into(),
208            SymbolEntry {
209                file: "b.rs".into(),
210                name: "foo".into(),
211                kind: "fn".into(),
212                start_line: 1,
213                end_line: 5,
214                is_exported: false,
215            },
216        );
217
218        let map = build_symbol_location_map(&index);
219        assert!(map.contains_key("foo"));
220    }
221
222    #[test]
223    fn repo_graph_deduplicates_edges() {
224        let mut index = ProjectIndex::new("/tmp");
225        index.files.insert("a.rs".into(), dummy_file_entry("a.rs"));
226        index.files.insert("b.rs".into(), dummy_file_entry("b.rs"));
227        index.edges.push(graph_index::IndexEdge {
228            from: "a.rs".into(),
229            to: "b.rs".into(),
230            kind: "import".into(),
231            weight: 1.0,
232        });
233        index.edges.push(graph_index::IndexEdge {
234            from: "a.rs".into(),
235            to: "b.rs".into(),
236            kind: "import".into(),
237            weight: 1.0,
238        });
239
240        let call_graph = CallGraph::new("/tmp");
241        let graph = RepoGraph::from_index_and_calls(&index, &call_graph, &HashMap::new());
242
243        let a_deps = graph.forward.get("a.rs").unwrap();
244        assert_eq!(a_deps.len(), 1, "duplicate edges should be deduped");
245    }
246
247    #[test]
248    fn repo_graph_ignores_self_edges() {
249        let mut index = ProjectIndex::new("/tmp");
250        index.files.insert("a.rs".into(), dummy_file_entry("a.rs"));
251        index.edges.push(graph_index::IndexEdge {
252            from: "a.rs".into(),
253            to: "a.rs".into(),
254            kind: "import".into(),
255            weight: 1.0,
256        });
257
258        let call_graph = CallGraph::new("/tmp");
259        let graph = RepoGraph::from_index_and_calls(&index, &call_graph, &HashMap::new());
260
261        assert!(
262            !graph.forward.contains_key("a.rs"),
263            "self-edges should be excluded"
264        );
265    }
266
267    fn dummy_file_entry(path: &str) -> graph_index::FileEntry {
268        graph_index::FileEntry {
269            path: path.into(),
270            hash: "abc".into(),
271            language: "rust".into(),
272            line_count: 10,
273            token_count: 50,
274            exports: vec![],
275            summary: String::new(),
276        }
277    }
278}