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                && 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        // Deduplicate edges
79        for deps in forward.values_mut() {
80            deps.sort();
81            deps.dedup();
82        }
83
84        let symbols_by_file = build_symbols_with_signatures(index, content_cache);
85
86        Self {
87            files,
88            forward,
89            symbols_by_file,
90        }
91    }
92}
93
94/// Map lowercase symbol name -> file path (first definition wins).
95fn build_symbol_location_map(index: &ProjectIndex) -> HashMap<String, String> {
96    let mut map: HashMap<String, String> = HashMap::with_capacity(index.symbols.len());
97    for sym in index.symbols.values() {
98        map.entry(sym.name.to_lowercase())
99            .or_insert_with(|| sym.file.clone());
100    }
101    map
102}
103
104/// Build symbol definitions with compact signatures from file contents.
105fn build_symbols_with_signatures(
106    index: &ProjectIndex,
107    content_cache: &HashMap<String, String>,
108) -> HashMap<String, Vec<SymbolDef>> {
109    let mut result: HashMap<String, Vec<SymbolDef>> = HashMap::new();
110
111    // Group index symbols by file
112    let mut idx_symbols: HashMap<&str, Vec<&SymbolEntry>> = HashMap::new();
113    for sym in index.symbols.values() {
114        idx_symbols.entry(sym.file.as_str()).or_default().push(sym);
115    }
116
117    for (file_path, file_entry) in &index.files {
118        let ext = std::path::Path::new(file_path)
119            .extension()
120            .and_then(|e| e.to_str())
121            .unwrap_or("");
122
123        // Extract signatures from file content if available
124        let signatures = content_cache
125            .get(file_path)
126            .map(|content| crate::core::signatures::extract_signatures(content, ext))
127            .unwrap_or_default();
128
129        let sig_by_name: HashMap<&str, &crate::core::signatures::Signature> =
130            signatures.iter().map(|s| (s.name.as_str(), s)).collect();
131
132        let mut file_symbols: Vec<SymbolDef> = Vec::new();
133
134        if let Some(syms) = idx_symbols.get(file_path.as_str()) {
135            for sym in syms {
136                let signature = sig_by_name
137                    .get(sym.name.as_str())
138                    .map_or_else(|| format!("{} {}", sym.kind, sym.name), |s| s.to_compact());
139
140                file_symbols.push(SymbolDef {
141                    name: sym.name.clone(),
142                    kind: sym.kind.clone(),
143                    file: sym.file.clone(),
144                    line: sym.start_line,
145                    end_line: sym.end_line,
146                    is_exported: sym.is_exported,
147                    signature,
148                });
149            }
150        }
151
152        // Also include exports from file entry that may not be in the symbols map
153        for export in &file_entry.exports {
154            let already_present = file_symbols.iter().any(|s| s.name == *export);
155            if !already_present {
156                let signature = sig_by_name
157                    .get(export.as_str())
158                    .map_or_else(|| export.clone(), |s| s.to_compact());
159
160                let (line, end_line) = sig_by_name
161                    .get(export.as_str())
162                    .and_then(|s| s.start_line.zip(s.end_line))
163                    .unwrap_or((0, 0));
164
165                file_symbols.push(SymbolDef {
166                    name: export.clone(),
167                    kind: "export".to_string(),
168                    file: file_path.clone(),
169                    line,
170                    end_line,
171                    is_exported: true,
172                    signature,
173                });
174            }
175        }
176
177        file_symbols.sort_by_key(|s| s.line);
178
179        if !file_symbols.is_empty() {
180            result.insert(file_path.clone(), file_symbols);
181        }
182    }
183
184    result
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn symbol_location_map_uses_first_definition() {
193        let mut index = ProjectIndex::new("/tmp");
194        index.symbols.insert(
195            "a::foo".into(),
196            SymbolEntry {
197                file: "a.rs".into(),
198                name: "foo".into(),
199                kind: "fn".into(),
200                start_line: 1,
201                end_line: 10,
202                is_exported: true,
203            },
204        );
205        index.symbols.insert(
206            "b::foo".into(),
207            SymbolEntry {
208                file: "b.rs".into(),
209                name: "foo".into(),
210                kind: "fn".into(),
211                start_line: 1,
212                end_line: 5,
213                is_exported: false,
214            },
215        );
216
217        let map = build_symbol_location_map(&index);
218        assert!(map.contains_key("foo"));
219    }
220
221    #[test]
222    fn repo_graph_deduplicates_edges() {
223        let mut index = ProjectIndex::new("/tmp");
224        index.files.insert("a.rs".into(), dummy_file_entry("a.rs"));
225        index.files.insert("b.rs".into(), dummy_file_entry("b.rs"));
226        index.edges.push(graph_index::IndexEdge {
227            from: "a.rs".into(),
228            to: "b.rs".into(),
229            kind: "import".into(),
230            weight: 1.0,
231        });
232        index.edges.push(graph_index::IndexEdge {
233            from: "a.rs".into(),
234            to: "b.rs".into(),
235            kind: "import".into(),
236            weight: 1.0,
237        });
238
239        let call_graph = CallGraph::new("/tmp");
240        let graph = RepoGraph::from_index_and_calls(&index, &call_graph, &HashMap::new());
241
242        let a_deps = graph.forward.get("a.rs").unwrap();
243        assert_eq!(a_deps.len(), 1, "duplicate edges should be deduped");
244    }
245
246    #[test]
247    fn repo_graph_ignores_self_edges() {
248        let mut index = ProjectIndex::new("/tmp");
249        index.files.insert("a.rs".into(), dummy_file_entry("a.rs"));
250        index.edges.push(graph_index::IndexEdge {
251            from: "a.rs".into(),
252            to: "a.rs".into(),
253            kind: "import".into(),
254            weight: 1.0,
255        });
256
257        let call_graph = CallGraph::new("/tmp");
258        let graph = RepoGraph::from_index_and_calls(&index, &call_graph, &HashMap::new());
259
260        assert!(
261            !graph.forward.contains_key("a.rs"),
262            "self-edges should be excluded"
263        );
264    }
265
266    fn dummy_file_entry(path: &str) -> graph_index::FileEntry {
267        graph_index::FileEntry {
268            path: path.into(),
269            hash: "abc".into(),
270            language: "rust".into(),
271            line_count: 10,
272            token_count: 50,
273            exports: vec![],
274            summary: String::new(),
275        }
276    }
277}