Skip to main content

nusy_codegraph/
scip_calls.rs

1//! SCIP-based call edge extraction.
2//!
3//! Uses rust-analyzer's SCIP output to derive high-fidelity `Calls` edges.
4//! SCIP provides compiler-quality definition/reference data; we correlate
5//! references inside function body ranges to produce call edges.
6//!
7//! ## Pipeline
8//!
9//! 1. Run `rust-analyzer scip .` on the target crate (external process)
10//! 2. Parse the SCIP protobuf output
11//! 3. Build a map: symbol → definition location (file + range)
12//! 4. For each function body, find all reference occurrences
13//! 5. Match references to known definitions → emit `CodeEdge::Calls`
14
15use crate::schema::{CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind};
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20/// Result of SCIP-based call extraction.
21#[derive(Debug)]
22pub struct ScipCallResult {
23    /// Call edges derived from SCIP data.
24    pub edges: Vec<CodeEdge>,
25    /// Number of SCIP documents (files) processed.
26    pub documents_processed: usize,
27    /// Number of symbols resolved.
28    pub symbols_resolved: usize,
29    /// Number of references that couldn't be resolved to a CodeNode.
30    pub unresolved_references: usize,
31    /// Warnings/errors encountered.
32    pub warnings: Vec<String>,
33}
34
35/// Run `rust-analyzer scip` on a crate directory and return the SCIP index path.
36///
37/// Returns `None` if rust-analyzer is not installed or fails.
38pub fn generate_scip_index(crate_dir: &Path) -> Option<PathBuf> {
39    let output_path = crate_dir.join("index.scip");
40
41    let result = Command::new("rust-analyzer")
42        .arg("scip")
43        .arg(".")
44        .arg("--output")
45        .arg(&output_path)
46        .current_dir(crate_dir)
47        .output();
48
49    match result {
50        Ok(output) if output.status.success() => {
51            if output_path.exists() {
52                Some(output_path)
53            } else {
54                tracing::warn!("rust-analyzer scip succeeded but no output file");
55                None
56            }
57        }
58        Ok(output) => {
59            let stderr = String::from_utf8_lossy(&output.stderr);
60            // rust-analyzer writes progress to stderr even on success;
61            // check if the file was actually created
62            if output_path.exists() {
63                Some(output_path)
64            } else {
65                tracing::warn!("rust-analyzer scip failed: {stderr}");
66                None
67            }
68        }
69        Err(e) => {
70            tracing::info!("rust-analyzer not available, skipping SCIP: {e}");
71            None
72        }
73    }
74}
75
76/// Extract call edges from a SCIP index file, mapping to existing CodeNodes.
77pub fn extract_calls_from_scip(
78    scip_path: &Path,
79    nodes: &[CodeNode],
80    crate_root: &Path,
81) -> ScipCallResult {
82    let data = match std::fs::read(scip_path) {
83        Ok(d) => d,
84        Err(e) => {
85            return ScipCallResult {
86                edges: vec![],
87                documents_processed: 0,
88                symbols_resolved: 0,
89                unresolved_references: 0,
90                warnings: vec![format!("Failed to read SCIP file: {e}")],
91            };
92        }
93    };
94
95    let index: scip::types::Index = match protobuf::Message::parse_from_bytes(&data) {
96        Ok(idx) => idx,
97        Err(e) => {
98            return ScipCallResult {
99                edges: vec![],
100                documents_processed: 0,
101                symbols_resolved: 0,
102                unresolved_references: 0,
103                warnings: vec![format!("Failed to parse SCIP protobuf: {e}")],
104            };
105        }
106    };
107
108    // Build lookup tables from our existing CodeNodes.
109    let node_by_file_line = build_node_lookup(nodes);
110    let node_by_name = build_name_lookup(nodes);
111
112    // Build SCIP symbol → definition location map.
113    let mut symbol_defs: HashMap<String, SymbolDef> = HashMap::new();
114    let mut documents_processed = 0;
115
116    for doc in &index.documents {
117        documents_processed += 1;
118        let rel_path = &doc.relative_path;
119
120        for occ in &doc.occurrences {
121            let symbol = &occ.symbol;
122            if symbol.is_empty() || symbol.starts_with("local ") {
123                continue;
124            }
125
126            let is_def = occ.symbol_roles & scip::types::SymbolRole::Definition as i32 != 0;
127            if is_def && occ.range.len() >= 3 {
128                let line = occ.range[0] as u32 + 1;
129                let col = occ.range[1] as u32;
130                symbol_defs.insert(
131                    symbol.clone(),
132                    SymbolDef {
133                        file: rel_path.clone(),
134                        line,
135                        col,
136                        symbol: symbol.clone(),
137                    },
138                );
139            }
140        }
141    }
142
143    // Now find call edges: for each function node, find references in its body range.
144    let mut edges = Vec::new();
145    let mut symbols_resolved = 0;
146    let mut unresolved_references = 0;
147
148    // Build a map of file → [(occurrence, symbol, is_reference)]
149    type RefEntry = (u32, u32, u32, u32, String);
150    let mut file_refs: HashMap<String, Vec<RefEntry>> = HashMap::new();
151    for doc in &index.documents {
152        let rel_path = &doc.relative_path;
153        for occ in &doc.occurrences {
154            let symbol = &occ.symbol;
155            if symbol.is_empty() || symbol.starts_with("local ") {
156                continue;
157            }
158            let is_ref = occ.symbol_roles & scip::types::SymbolRole::Definition as i32 == 0;
159            if is_ref && occ.range.len() >= 3 {
160                let (start_line, start_col) = (occ.range[0] as u32 + 1, occ.range[1] as u32);
161                let (end_line, end_col) = if occ.range.len() >= 4 {
162                    (occ.range[2] as u32, occ.range[3] as u32)
163                } else {
164                    (occ.range[0] as u32 + 1, occ.range[2] as u32)
165                };
166                file_refs.entry(rel_path.clone()).or_default().push((
167                    start_line,
168                    start_col,
169                    end_line,
170                    end_col,
171                    symbol.clone(),
172                ));
173            }
174        }
175    }
176
177    // For each function/method node, check which references fall inside its body range.
178    let callable_kinds = [
179        CodeNodeKind::RustFn,
180        CodeNodeKind::RustMethod,
181        CodeNodeKind::RustTest,
182    ];
183
184    for node in nodes {
185        if !callable_kinds.contains(&node.kind) {
186            continue;
187        }
188
189        let (Some(start), Some(end)) = (node.start_line, node.end_line) else {
190            continue;
191        };
192        let Some(ref file_path) = node.file_path else {
193            continue;
194        };
195
196        // Normalize path relative to crate root.
197        let rel_file = file_path
198            .strip_prefix(crate_root.to_str().unwrap_or(""))
199            .unwrap_or(file_path)
200            .trim_start_matches('/');
201
202        let Some(refs) = file_refs.get(rel_file) else {
203            continue;
204        };
205
206        // Collect unique call targets from references in this function's body.
207        let mut seen_targets = std::collections::HashSet::new();
208
209        for (ref_line, _ref_col, _end_line, _end_col, symbol) in refs {
210            // Is this reference inside the function body?
211            if *ref_line < start || *ref_line > end {
212                continue;
213            }
214
215            // Does this symbol resolve to a known definition?
216            if let Some(def) = symbol_defs.get(symbol) {
217                // Try to find the target CodeNode.
218                let target_id = resolve_target(def, &node_by_file_line, &node_by_name);
219                if let Some(target_id) = target_id {
220                    if target_id != node.id && seen_targets.insert(target_id.clone()) {
221                        edges.push(CodeEdge {
222                            source_id: node.id.clone(),
223                            target_id,
224                            predicate: CodeEdgePredicate::Calls,
225                            weight: Some(1.0),
226                            commit_id: None,
227                        });
228                        symbols_resolved += 1;
229                    }
230                } else {
231                    unresolved_references += 1;
232                }
233            }
234        }
235    }
236
237    // Clean up the SCIP file.
238    let _ = std::fs::remove_file(scip_path);
239
240    ScipCallResult {
241        edges,
242        documents_processed,
243        symbols_resolved,
244        unresolved_references,
245        warnings: vec![],
246    }
247}
248
249/// Generate SCIP index and extract call edges in one step.
250///
251/// Returns empty result (not error) if rust-analyzer is not available.
252pub fn extract_scip_call_edges(crate_dir: &Path, nodes: &[CodeNode]) -> ScipCallResult {
253    let scip_path = match generate_scip_index(crate_dir) {
254        Some(p) => p,
255        None => {
256            return ScipCallResult {
257                edges: vec![],
258                documents_processed: 0,
259                symbols_resolved: 0,
260                unresolved_references: 0,
261                warnings: vec!["rust-analyzer not available; SCIP call extraction skipped".into()],
262            };
263        }
264    };
265
266    extract_calls_from_scip(&scip_path, nodes, crate_dir)
267}
268
269// ── Internal helpers ────────────────────────────────────────────────────────
270
271#[derive(Debug)]
272struct SymbolDef {
273    file: String,
274    line: u32,
275    #[allow(dead_code)]
276    col: u32,
277    #[allow(dead_code)]
278    symbol: String,
279}
280
281/// Build a lookup: (file, line) → CodeNode ID.
282fn build_node_lookup(nodes: &[CodeNode]) -> HashMap<(String, u32), String> {
283    let mut map = HashMap::new();
284    for node in nodes {
285        if let (Some(fp), Some(line)) = (&node.file_path, node.start_line) {
286            let key = (fp.clone(), line);
287            map.entry(key).or_insert_with(|| node.id.clone());
288        }
289    }
290    map
291}
292
293/// Build a lookup: short name → CodeNode ID (for fallback resolution).
294fn build_name_lookup(nodes: &[CodeNode]) -> HashMap<String, String> {
295    let mut map = HashMap::new();
296    let callable = [
297        CodeNodeKind::RustFn,
298        CodeNodeKind::RustMethod,
299        CodeNodeKind::RustTest,
300    ];
301    for node in nodes {
302        if callable.contains(&node.kind) && !node.name.is_empty() {
303            map.entry(node.name.clone())
304                .or_insert_with(|| node.id.clone());
305        }
306    }
307    map
308}
309
310/// Try to resolve a SCIP definition to a CodeNode ID.
311fn resolve_target(
312    def: &SymbolDef,
313    by_file_line: &HashMap<(String, u32), String>,
314    by_name: &HashMap<String, String>,
315) -> Option<String> {
316    // Primary: match by file path + line number.
317    let key = (def.file.clone(), def.line);
318    if let Some(id) = by_file_line.get(&key) {
319        return Some(id.clone());
320    }
321
322    // Try without leading "src/" prefix (SCIP uses relative paths).
323    if def.file.starts_with("src/") {
324        let stripped = def.file.strip_prefix("src/").unwrap_or(&def.file);
325        let key2 = (stripped.to_string(), def.line);
326        if let Some(id) = by_file_line.get(&key2) {
327            return Some(id.clone());
328        }
329    }
330
331    // Fallback: extract function name from SCIP symbol and match by name.
332    let name = extract_name_from_scip_symbol(&def.symbol);
333    if !name.is_empty()
334        && let Some(id) = by_name.get(&name)
335    {
336        return Some(id.clone());
337    }
338
339    None
340}
341
342/// Extract a short name from a SCIP symbol string.
343///
344/// SCIP symbols look like: `rust-analyzer cargo arrow-kanban 0.1.0 create_item().`
345/// We want the last identifier before the `()` or `.` suffix.
346fn extract_name_from_scip_symbol(symbol: &str) -> String {
347    // Take the last space-separated token and strip trailing punctuation.
348    symbol
349        .split_whitespace()
350        .last()
351        .unwrap_or("")
352        .trim_end_matches('.')
353        .trim_end_matches("()")
354        .trim_end_matches('#')
355        .to_string()
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn extract_name_from_symbol() {
364        assert_eq!(
365            extract_name_from_scip_symbol("rust-analyzer cargo arrow-kanban 0.1.0 create_item()."),
366            "create_item"
367        );
368        assert_eq!(
369            extract_name_from_scip_symbol("rust-analyzer cargo arrow-kanban 0.1.0 KanbanStore#"),
370            "KanbanStore"
371        );
372        assert_eq!(
373            extract_name_from_scip_symbol("rust-analyzer cargo arrow-kanban 0.1.0 crate/crud.rs/"),
374            "crate/crud.rs/"
375        );
376        assert_eq!(extract_name_from_scip_symbol(""), "");
377    }
378
379    #[test]
380    fn build_lookups_from_nodes() {
381        let nodes = vec![
382            CodeNode {
383                id: "rust_fn:src/crud.rs::create_item".into(),
384                kind: CodeNodeKind::RustFn,
385                name: "create_item".into(),
386                file_path: Some("src/crud.rs".into()),
387                start_line: Some(10),
388                end_line: Some(50),
389                ..Default::default()
390            },
391            CodeNode {
392                id: "rust_fn:src/crud.rs::move_item".into(),
393                kind: CodeNodeKind::RustFn,
394                name: "move_item".into(),
395                file_path: Some("src/crud.rs".into()),
396                start_line: Some(55),
397                end_line: Some(90),
398                ..Default::default()
399            },
400        ];
401
402        let by_fl = build_node_lookup(&nodes);
403        assert_eq!(
404            by_fl.get(&("src/crud.rs".into(), 10u32)),
405            Some(&"rust_fn:src/crud.rs::create_item".to_string())
406        );
407
408        let by_name = build_name_lookup(&nodes);
409        assert_eq!(
410            by_name.get("create_item"),
411            Some(&"rust_fn:src/crud.rs::create_item".to_string())
412        );
413    }
414
415    #[test]
416    fn scip_result_defaults_empty() {
417        let result = ScipCallResult {
418            edges: vec![],
419            documents_processed: 0,
420            symbols_resolved: 0,
421            unresolved_references: 0,
422            warnings: vec![],
423        };
424        assert!(result.edges.is_empty());
425    }
426}