1use crate::schema::{CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind};
16use std::collections::HashMap;
17use std::path::{Path, PathBuf};
18use std::process::Command;
19
20#[derive(Debug)]
22pub struct ScipCallResult {
23 pub edges: Vec<CodeEdge>,
25 pub documents_processed: usize,
27 pub symbols_resolved: usize,
29 pub unresolved_references: usize,
31 pub warnings: Vec<String>,
33}
34
35pub 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 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
76pub 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 let node_by_file_line = build_node_lookup(nodes);
110 let node_by_name = build_name_lookup(nodes);
111
112 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 let mut edges = Vec::new();
145 let mut symbols_resolved = 0;
146 let mut unresolved_references = 0;
147
148 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 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 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 let mut seen_targets = std::collections::HashSet::new();
208
209 for (ref_line, _ref_col, _end_line, _end_col, symbol) in refs {
210 if *ref_line < start || *ref_line > end {
212 continue;
213 }
214
215 if let Some(def) = symbol_defs.get(symbol) {
217 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 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
249pub 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#[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
281fn 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
293fn 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
310fn resolve_target(
312 def: &SymbolDef,
313 by_file_line: &HashMap<(String, u32), String>,
314 by_name: &HashMap<String, String>,
315) -> Option<String> {
316 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 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 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
342fn extract_name_from_scip_symbol(symbol: &str) -> String {
347 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}