Skip to main content

cargo_perspective/
analysis.rs

1//! AST-based analysis to detect and count dependency method calls.
2
3use crate::types::{CallSite, ProjectAnalysis};
4use anyhow::{Context, Result};
5use cargo_metadata::{MetadataCommand, PackageId};
6use std::collections::HashSet;
7use std::path::Path;
8use syn::visit::{self, Visit};
9use walkdir::WalkDir;
10
11/// Analyzes a Rust project and counts dependency method calls.
12pub struct DependencyAnalyzer {
13    /// Cargo metadata
14    metadata: cargo_metadata::Metadata,
15    /// Set of direct dependency names
16    direct_deps: HashSet<String>,
17    /// Set of all dependency names (direct + transitive)
18    all_deps: HashSet<String>,
19}
20
21impl DependencyAnalyzer {
22    /// Create a new analyzer for the current project.
23    pub fn new() -> Result<Self> {
24        let metadata = MetadataCommand::new()
25            .exec()
26            .context("Failed to execute cargo metadata")?;
27
28        let resolve = metadata
29            .resolve
30            .as_ref()
31            .context("No resolve information in metadata")?;
32
33        let root_id = resolve
34            .root
35            .as_ref()
36            .context("No root package in resolve")?;
37
38        // Get direct dependencies
39        let direct_deps = Self::get_direct_dependency_names(&metadata, root_id)?;
40
41        // Get all dependencies
42        let all_deps = Self::get_all_dependency_names(&metadata, root_id)?;
43
44        Ok(Self {
45            metadata,
46            direct_deps,
47            all_deps,
48        })
49    }
50
51    /// Analyze the project and return call statistics.
52    pub fn analyze(&self) -> Result<ProjectAnalysis> {
53        let resolve = self
54            .metadata
55            .resolve
56            .as_ref()
57            .context("No resolve information")?;
58
59        let root_id = resolve.root.as_ref().context("No root package")?;
60        let root_pkg = self
61            .metadata
62            .packages
63            .iter()
64            .find(|p| &p.id == root_id)
65            .context("Root package not found")?;
66
67        let mut analysis = ProjectAnalysis::new(root_pkg.name.clone());
68
69        // Scan all .rs files in src/
70        if !Path::new("src").exists() {
71            return Ok(analysis);
72        }
73
74        for entry in WalkDir::new("src")
75            .into_iter()
76            .filter_map(|e| e.ok())
77            .filter(|e| e.path().extension().map_or(false, |ext| ext == "rs"))
78        {
79            let file_path = entry.path();
80            self.analyze_file(file_path, &mut analysis)?;
81        }
82
83        analysis.recalculate_totals();
84        Ok(analysis)
85    }
86
87    /// Analyze a single Rust file for dependency calls.
88    fn analyze_file(&self, file_path: &Path, analysis: &mut ProjectAnalysis) -> Result<()> {
89        let content = std::fs::read_to_string(file_path)
90            .with_context(|| format!("Failed to read file: {:?}", file_path))?;
91
92        let syntax_tree = syn::parse_file(&content).with_context(|| {
93            format!("Failed to parse Rust file: {:?}", file_path)
94        })?;
95
96        let mut visitor = CallVisitor {
97            file_path: file_path.to_string_lossy().to_string(),
98            direct_deps: &self.direct_deps,
99            all_deps: &self.all_deps,
100            analysis,
101        };
102
103        visitor.visit_file(&syntax_tree);
104        Ok(())
105    }
106
107    fn get_direct_dependency_names(
108        metadata: &cargo_metadata::Metadata,
109        root_id: &PackageId,
110    ) -> Result<HashSet<String>> {
111        let root_pkg = metadata
112            .packages
113            .iter()
114            .find(|p| &p.id == root_id)
115            .context("Root package not found")?;
116
117        Ok(root_pkg
118            .dependencies
119            .iter()
120            .map(|d| d.name.clone())
121            .collect())
122    }
123
124    fn get_all_dependency_names(
125        metadata: &cargo_metadata::Metadata,
126        root_id: &PackageId,
127    ) -> Result<HashSet<String>> {
128        let resolve = metadata
129            .resolve
130            .as_ref()
131            .context("No resolve information")?;
132
133        let all_dep_ids: HashSet<&PackageId> = resolve
134            .nodes
135            .iter()
136            .filter(|n| &n.id != root_id)
137            .map(|n| &n.id)
138            .collect();
139
140        Ok(metadata
141            .packages
142            .iter()
143            .filter(|p| all_dep_ids.contains(&p.id))
144            .map(|p| p.name.clone())
145            .collect())
146    }
147}
148
149/// AST visitor to find and count method calls.
150struct CallVisitor<'a> {
151    file_path: String,
152    direct_deps: &'a HashSet<String>,
153    all_deps: &'a HashSet<String>,
154    analysis: &'a mut ProjectAnalysis,
155}
156
157impl<'a> CallVisitor<'a> {
158    /// Check if a path segment represents a dependency call.
159    fn extract_crate_and_method(&self, path: &syn::Path) -> Option<(String, String)> {
160        if path.segments.is_empty() {
161            return None;
162        }
163
164        // First segment is usually the crate name
165        let first_segment = path.segments.first()?.ident.to_string();
166
167        // Check if this is a known dependency
168        if !self.all_deps.contains(&first_segment) {
169            return None;
170        }
171
172        // Build the full path
173        let full_path: Vec<String> = path
174            .segments
175            .iter()
176            .map(|seg| seg.ident.to_string())
177            .collect();
178
179        Some((first_segment, full_path.join("::")))
180    }
181
182    fn record_call(&mut self, crate_name: String, method_path: String, line: usize, column: usize) {
183        let is_direct = self.direct_deps.contains(&crate_name);
184        let crate_usage = self.analysis.get_or_create_crate(crate_name, is_direct);
185
186        let call_site = CallSite {
187            file_path: self.file_path.clone(),
188            line,
189            column,
190        };
191
192        crate_usage.add_call(method_path, call_site);
193    }
194}
195
196impl<'a> Visit<'a> for CallVisitor<'a> {
197    fn visit_expr_call(&mut self, node: &'a syn::ExprCall) {
198        // Handle function calls like tokio::spawn(...)
199        if let syn::Expr::Path(expr_path) = &*node.func {
200            if let Some((crate_name, method_path)) = self.extract_crate_and_method(&expr_path.path)
201            {
202                // Note: Extracting precise line/column from syn requires additional work
203                // For MVP, we'll use 0,0 as placeholder
204                self.record_call(crate_name, method_path, 0, 0);
205            }
206        }
207
208        // Continue visiting child nodes
209        visit::visit_expr_call(self, node);
210    }
211
212    fn visit_expr_method_call(&mut self, node: &'a syn::ExprMethodCall) {
213        // Handle method calls - try to infer the type
214        // This is a simplified heuristic; proper type inference would require running rustc
215        let method_name = node.method.to_string();
216
217        // Try to extract crate from receiver if it's a path
218        if let syn::Expr::Path(expr_path) = &*node.receiver {
219            if let Some((crate_name, base_path)) = self.extract_crate_and_method(&expr_path.path)
220            {
221                let full_method_path = format!("{}::{}", base_path, method_name);
222                self.record_call(crate_name, full_method_path, 0, 0);
223            }
224        }
225
226        // Continue visiting child nodes
227        visit::visit_expr_method_call(self, node);
228    }
229
230    fn visit_macro(&mut self, node: &'a syn::Macro) {
231        // Handle macro calls like println!, tokio::select!, etc.
232        if let Some((crate_name, method_path)) = self.extract_crate_and_method(&node.path) {
233            self.record_call(crate_name, method_path, 0, 0);
234        }
235
236        // Continue visiting
237        visit::visit_macro(self, node);
238    }
239
240    fn visit_use_tree(&mut self, node: &'a syn::UseTree) {
241        // Track use statements for better analysis (future enhancement)
242        visit::visit_use_tree(self, node);
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_analyzer_creation() {
252        // This test will only pass in a cargo project
253        // In a real test suite, we'd use fixtures
254        let result = DependencyAnalyzer::new();
255        assert!(result.is_ok() || result.is_err()); // Just check it doesn't panic
256    }
257}