diff --git a/src/ast_parser.rs b/src/ast_parser.rs index 3ba2ab1..706d7ea 100644 --- a/src/ast_parser.rs +++ b/src/ast_parser.rs @@ -21,6 +21,7 @@ pub struct FunctionInfo { pub return_type_complexity: usize, pub line_start: usize, pub line_end: usize, + pub is_test_support: bool, } /// Extracted information about a struct. @@ -55,6 +56,22 @@ pub struct TraitInfo { pub generic_param_count: usize, } +/// Extracted information about a const item. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ConstInfo { + pub name: String, + pub file: PathBuf, +} + +/// Extracted information about a static item. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct StaticInfo { + pub name: String, + pub file: PathBuf, +} + /// Extracted information about an impl block. #[derive(Debug, Clone)] #[allow(dead_code)] @@ -75,6 +92,8 @@ pub struct FileSymbols { pub enums: Vec, pub traits: Vec, pub impls: Vec, + pub consts: Vec, + pub statics: Vec, pub parse_error: bool, } @@ -108,6 +127,8 @@ fn parse_file(path: &Path) -> FileSymbols { enums: vec![], traits: vec![], impls: vec![], + consts: vec![], + statics: vec![], parse_error: true, }; } @@ -123,6 +144,8 @@ fn parse_file(path: &Path) -> FileSymbols { enums: vec![], traits: vec![], impls: vec![], + consts: vec![], + statics: vec![], parse_error: true, }; } @@ -135,7 +158,11 @@ fn parse_file(path: &Path) -> FileSymbols { enums: vec![], traits: vec![], impls: vec![], + consts: vec![], + statics: vec![], module_parts: vec![], + in_cfg_test: false, + in_tests_module: false, }; for item in &ast.items { @@ -149,6 +176,8 @@ fn parse_file(path: &Path) -> FileSymbols { enums: extractor.enums, traits: extractor.traits, impls: extractor.impls, + consts: extractor.consts, + statics: extractor.statics, parse_error: false, } } @@ -160,7 +189,11 @@ struct SymbolExtractor { enums: Vec, traits: Vec, impls: Vec, + consts: Vec, + statics: Vec, module_parts: Vec, + in_cfg_test: bool, + in_tests_module: bool, } impl SymbolExtractor { @@ -168,8 +201,15 @@ impl SymbolExtractor { match item { Item::Fn(func) => { let name = self.qualify_name(&func.sig.ident.to_string()); - let info = - extract_function_info(&name, &self.file_path, &func.sig, Some(&func.block)); + let is_test_support = + self.is_test_support_context() || has_cfg_test_attr(&func.attrs); + let info = extract_function_info( + &name, + &self.file_path, + &func.sig, + Some(&func.block), + is_test_support, + ); self.functions.push(info); } Item::Struct(s) => { @@ -213,6 +253,18 @@ impl SymbolExtractor { generic_param_count, }); } + Item::Const(c) => { + self.consts.push(ConstInfo { + name: self.qualify_name(&c.ident.to_string()), + file: self.file_path.clone(), + }); + } + Item::Static(s) => { + self.statics.push(StaticInfo { + name: self.qualify_name(&s.ident.to_string()), + file: self.file_path.clone(), + }); + } Item::Impl(imp) => { let target_type = self.qualify_name(&type_to_string(&imp.self_ty)); let trait_name = imp.trait_.as_ref().map(|(_, path, _)| { @@ -232,6 +284,9 @@ impl SymbolExtractor { &self.file_path, &method.sig, Some(&method.block), + self.is_test_support_context() + || has_cfg_test_attr(&method.attrs) + || has_cfg_test_attr(&imp.attrs), ); self.functions.push(info); } @@ -245,11 +300,17 @@ impl SymbolExtractor { } Item::Mod(module) => { if let Some((_, items)) = &module.content { + let previous_cfg = self.in_cfg_test; + let previous_tests = self.in_tests_module; + self.in_cfg_test |= has_cfg_test_attr(&module.attrs); + self.in_tests_module |= module.ident == "tests"; self.module_parts.push(module.ident.to_string()); for item in items { self.extract_item(item); } self.module_parts.pop(); + self.in_cfg_test = previous_cfg; + self.in_tests_module = previous_tests; } } _ => {} @@ -263,6 +324,10 @@ impl SymbolExtractor { format!("{}::{}", self.module_parts.join("::"), name) } } + + fn is_test_support_context(&self) -> bool { + self.in_cfg_test || self.in_tests_module + } } fn extract_function_info( @@ -270,6 +335,7 @@ fn extract_function_info( file: &Path, sig: &syn::Signature, body: Option<&syn::Block>, + is_test_support: bool, ) -> FunctionInfo { let param_count = sig.inputs.len(); let generic_param_count = sig.generics.params.len(); @@ -314,6 +380,7 @@ fn extract_function_info( return_type_complexity, line_start, line_end, + is_test_support, } } @@ -322,6 +389,19 @@ fn count_derives(attrs: &[syn::Attribute]) -> usize { attrs.iter().filter(|a| a.path().is_ident("derive")).count() } +fn has_cfg_test_attr(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| { + attr.path() + .segments + .last() + .is_some_and(|segment| segment.ident == "cfg") + && match &attr.meta { + syn::Meta::List(list) => list.tokens.to_string().contains("test"), + _ => false, + } + }) +} + /// Count trait bounds across all generic parameters. fn count_generic_trait_bounds(generics: &syn::Generics) -> usize { let mut count = 0; diff --git a/src/call_trace.rs b/src/call_trace.rs index 1b57906..4ac0a11 100644 --- a/src/call_trace.rs +++ b/src/call_trace.rs @@ -6,6 +6,7 @@ use std::path::{Path, PathBuf}; use walkdir::WalkDir; use crate::ast_parser; +use crate::file_metrics; use crate::flow::{self, CallGraph}; use crate::render; @@ -27,10 +28,25 @@ enum EntrySource { } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] -struct TraceNode { - function: String, - cycle: bool, - children: Vec, +pub struct TraceNode { + pub function: String, + pub cycle: bool, + pub children: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FunctionTraceSummary { + pub function: String, + pub functions_reached: usize, + pub max_depth: usize, + pub direct_callees: Vec, + pub tree: TraceNode, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileCallTraceReport { + pub file: String, + pub functions: Vec, } #[derive(Debug, Clone, Serialize)] @@ -149,6 +165,121 @@ fn analyze( max_depth, }) } + +pub fn run_file( + rs_files: &[PathBuf], + project_path: &Path, + file: &Path, + entry: Option<&str>, + json: bool, + verbose: bool, +) -> Result<(), String> { + let symbols = ast_parser::parse_project(rs_files); + let graph = flow::build_call_graph(rs_files, project_path); + let file_functions = + file_metrics::selected_file_functions(&symbols, &graph, project_path, file); + let file_display_path = relative_path_string(project_path, file); + let report = analyze_file_call_traces(&graph, &file_functions, &file_display_path); + + let output = if let Some(entry) = entry { + let function = resolve_file_entry(&report, entry)?; + FileCallTraceReport { + file: report.file.clone(), + functions: vec![function.clone()], + } + } else { + report + }; + + if json { + println!("{}", serde_json::to_string(&output).unwrap()); + } else { + render_file_call_trace_report(&output, verbose); + } + + Ok(()) +} + +pub fn analyze_file_call_traces( + graph: &CallGraph, + file_functions: &file_metrics::FileFunctionIndex, + file_display_path: &str, +) -> FileCallTraceReport { + let mut functions: Vec = file_functions + .functions + .iter() + .filter(|function| !function.is_test && !function.is_bench) + .map(|function| { + let direct_callees = graph + .adjacency + .get(&function.graph_name) + .cloned() + .unwrap_or_default(); + let (tree, functions_reached, max_depth) = + if graph.functions.contains(&function.graph_name) { + build_trace_tree(graph, &function.graph_name) + } else { + ( + TraceNode { + function: function.graph_name.clone(), + cycle: false, + children: Vec::new(), + }, + 1, + 1, + ) + }; + + FunctionTraceSummary { + function: function.display_name.clone(), + functions_reached, + max_depth, + direct_callees, + tree, + } + }) + .collect(); + + functions.sort_by(|a, b| { + b.functions_reached + .cmp(&a.functions_reached) + .then(b.max_depth.cmp(&a.max_depth)) + .then(a.function.cmp(&b.function)) + }); + + FileCallTraceReport { + file: file_display_path.to_string(), + functions, + } +} + +fn resolve_file_entry( + report: &FileCallTraceReport, + requested: &str, +) -> Result { + if let Some(exact) = report + .functions + .iter() + .find(|function| function.function == requested) + { + return Ok(exact.clone()); + } + + let suffix = format!("::{requested}"); + let matches: Vec<&FunctionTraceSummary> = report + .functions + .iter() + .filter(|function| function.function.ends_with(&suffix) || function.function == requested) + .collect(); + + if matches.len() == 1 { + Ok(matches[0].clone()) + } else { + Err(format!( + "entry function not found or ambiguous: {requested}" + )) + } +} fn maybe_entry_listing( project_path: &Path, requested: &str, @@ -545,14 +676,14 @@ fn main_function_name(project_path: &Path, path: &Path) -> String { } } -fn relative_path_string(project_path: &Path, path: &Path) -> String { +pub(crate) fn relative_path_string(project_path: &Path, path: &Path) -> String { path.strip_prefix(project_path) .unwrap_or(path) .to_string_lossy() .replace('\\', "/") } -fn build_trace_tree(graph: &CallGraph, entry: &str) -> (TraceNode, usize, usize) { +pub(crate) fn build_trace_tree(graph: &CallGraph, entry: &str) -> (TraceNode, usize, usize) { let mut stack = HashSet::new(); let mut reached = HashSet::new(); stack.insert(entry.to_string()); @@ -678,6 +809,37 @@ fn render_human(output: &CallTraceJson, verbose: bool) { println!(" max depth: {}", output.max_depth); } +fn render_file_call_trace_report(report: &FileCallTraceReport, verbose: bool) { + println!("cstat file call trace ({})", report.file); + if verbose { + render::verbose_block(&[ + "Static call graph rendered for every non-test function in the selected file.", + "Cycles stop expansion; shared functions expand in each stack.", + ]); + } + + if report.functions.is_empty() { + println!(" "); + return; + } + + for function in &report.functions { + println!(); + println!( + "{} (functions reached: {}, max depth: {}, direct callees: {})", + function.function, + function.functions_reached, + function.max_depth, + if function.direct_callees.is_empty() { + "-".to_string() + } else { + function.direct_callees.join(", ") + } + ); + render_trace_node(&function.tree); + } +} + fn render_trace_node(node: &TraceNode) { println!("{}", format_node(node)); render_children(&node.children, ""); diff --git a/src/cluster.rs b/src/cluster.rs new file mode 100644 index 0000000..502ba82 --- /dev/null +++ b/src/cluster.rs @@ -0,0 +1,631 @@ +use colored::Colorize; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use syn::spanned::Spanned; +use syn::visit::{self, Visit}; +use syn::{BinOp, Block, Expr, ExprPath, FnArg, ImplItem, Item, Pat, Stmt}; + +use crate::render; + +const DISPLAY_FUNCTION_LIMIT: usize = 8; +const DISPLAY_CLUSTER_LIMIT: usize = 10; + +#[derive(Debug, Clone, Serialize)] +pub struct ClusterReport { + pub files: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileClusterReport { + pub path: String, + pub functions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FunctionClusterReport { + pub name: String, + pub line_start: usize, + pub line_end: usize, + pub is_test: bool, + pub reference_count: usize, + pub mutation_count: usize, + pub clusters: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ValueCluster { + pub name: String, + pub reference_lines: Vec, + pub mutation_lines: Vec, + pub connected_values: Vec, + #[serde(skip)] + sort_line: usize, +} + +#[derive(Debug, Clone)] +struct StatementAccess { + line_start: usize, + defines: Vec, + references: BTreeSet, + mutations: BTreeSet, +} + +#[derive(Debug, Clone)] +struct WorkingCluster { + name: String, + defined_line: usize, + reference_lines: BTreeSet, + mutation_lines: BTreeSet, + connected_values: BTreeSet, +} + +pub fn analyze_files(rs_files: &[PathBuf], project_path: &Path) -> ClusterReport { + let mut files = Vec::new(); + for file in rs_files { + if let Some(report) = analyze_file(file, project_path) { + files.push(report); + } + } + ClusterReport { files } +} + +pub fn analyze_file(path: &Path, project_path: &Path) -> Option { + let source = fs::read_to_string(path).ok()?; + let syntax = syn::parse_file(&source).ok()?; + let mut functions = Vec::new(); + collect_item_functions(&syntax.items, &mut Vec::new(), &mut functions); + + let mut function_reports = functions + .into_iter() + .filter_map(|function| analyze_function(function)) + .collect::>(); + + function_reports.sort_by(|a, b| { + a.is_test + .cmp(&b.is_test) + .then_with(|| b.reference_count.cmp(&a.reference_count)) + .then_with(|| b.mutation_count.cmp(&a.mutation_count)) + .then_with(|| a.line_start.cmp(&b.line_start)) + .then_with(|| a.name.cmp(&b.name)) + }); + + Some(FileClusterReport { + path: relative_path_string(project_path, path), + functions: function_reports, + }) +} + +pub fn render_cluster(rs_files: &[PathBuf], project_path: &Path, json: bool, verbose: bool) { + let report = analyze_files(rs_files, project_path); + if json { + println!("{}", serde_json::to_string_pretty(&report).unwrap()); + } else { + render_cluster_report(&report, verbose); + } +} + +pub fn render_cluster_file(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) { + let report = analyze_file(file, project_path).unwrap_or_else(|| FileClusterReport { + path: relative_path_string(project_path, file), + functions: Vec::new(), + }); + render_file_cluster_report(&report, verbose); + + // Keep signature parity with sibling modules that receive the full project file set. + let _ = rs_files; +} + +pub fn render_cluster_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) { + let report = analyze_file(file, project_path).unwrap_or_else(|| FileClusterReport { + path: relative_path_string(project_path, file), + functions: Vec::new(), + }); + println!("{}", serde_json::to_string_pretty(&report).unwrap()); + + let _ = rs_files; +} + +fn render_cluster_report(report: &ClusterReport, verbose: bool) { + render::section_header("Value clusters"); + if verbose { + render_cluster_explanation(); + } + + let mut functions = report + .files + .iter() + .flat_map(|file| { + file.functions + .iter() + .map(move |function| (&file.path, function)) + }) + .collect::>(); + functions.sort_by(|(path_a, a), (path_b, b)| { + a.is_test + .cmp(&b.is_test) + .then_with(|| b.reference_count.cmp(&a.reference_count)) + .then_with(|| b.mutation_count.cmp(&a.mutation_count)) + .then_with(|| path_a.cmp(path_b)) + .then_with(|| a.line_start.cmp(&b.line_start)) + .then_with(|| a.name.cmp(&b.name)) + }); + + if functions.is_empty() { + println!("No reusable value clusters found."); + return; + } + + for (path, function) in functions.into_iter().take(DISPLAY_FUNCTION_LIMIT) { + println!( + "{} {}:{}-{}", + "fn".cyan().bold(), + path, + function.line_start, + function.line_end + ); + render_function_clusters(function); + } +} + +fn render_file_cluster_report(report: &FileClusterReport, verbose: bool) { + render::section_header(&format!("Value clusters ({})", report.path)); + if verbose { + render_cluster_explanation(); + } + if report.functions.is_empty() { + println!("No reusable value clusters found."); + return; + } + + for function in report.functions.iter().take(DISPLAY_FUNCTION_LIMIT) { + println!( + "{} {}:{}-{}", + "fn".cyan().bold(), + function.name.bold(), + function.line_start, + function.line_end + ); + render_function_clusters(function); + } +} + +fn render_function_clusters(function: &FunctionClusterReport) { + println!(" value references mutations connected connected values"); + for cluster in function.clusters.iter().take(DISPLAY_CLUSTER_LIMIT) { + println!( + " {:<24} {:>10} {:>9} {:>9} {}", + cluster.name.bold(), + cluster.reference_lines.len(), + cluster.mutation_lines.len(), + cluster.connected_values.len(), + short_connected_values(&cluster.connected_values, 4).dimmed() + ); + } + + if function.clusters.len() > DISPLAY_CLUSTER_LIMIT { + println!( + " … {} more values hidden; use --json for the complete cluster record.", + function.clusters.len() - DISPLAY_CLUSTER_LIMIT + ); + } + println!(); +} + +fn short_connected_values(connected_values: &[String], limit: usize) -> String { + if connected_values.is_empty() { + return "-".to_string(); + } + let mut shown = connected_values + .iter() + .take(limit) + .cloned() + .collect::>(); + if connected_values.len() > limit { + shown.push(format!("+{} more", connected_values.len() - limit)); + } + shown.join(", ") +} + +fn render_cluster_explanation() { + render::verbose_line( + "value = a function parameter or local let-binding collected from the Rust AST.", + ); + render::verbose_line( + "references = top-level statements where that value is read after it is created.", + ); + render::verbose_line( + "mutations = top-level statements that assign to the value, compound-assign it, or pass it as &mut.", + ); + render::verbose_line( + "connected = other local values that appear in the same top-level statements.", + ); + println!(); +} + +struct FunctionCandidate<'a> { + name: String, + block: &'a Block, + line_start: usize, + line_end: usize, + params: Vec, + is_test: bool, +} + +fn collect_item_functions<'a>( + items: &'a [Item], + module_parts: &mut Vec, + out: &mut Vec>, +) { + for item in items { + match item { + Item::Fn(function) => { + let name = qualify_name(module_parts, &function.sig.ident.to_string()); + out.push(FunctionCandidate { + name, + block: &function.block, + line_start: function.sig.fn_token.span.start().line, + line_end: function.block.brace_token.span.close().end().line, + params: collect_fn_params(&function.sig.inputs), + is_test: module_parts.iter().any(|part| part == "tests") + || has_attr_named(&function.attrs, "test"), + }); + } + Item::Impl(implementation) => { + let target = type_name(&implementation.self_ty); + for impl_item in &implementation.items { + if let ImplItem::Fn(method) = impl_item { + out.push(FunctionCandidate { + name: format!("{}::{}", target, method.sig.ident), + block: &method.block, + line_start: method.sig.fn_token.span.start().line, + line_end: method.block.brace_token.span.close().end().line, + params: collect_fn_params(&method.sig.inputs), + is_test: module_parts.iter().any(|part| part == "tests") + || has_attr_named(&method.attrs, "test"), + }); + } + } + } + Item::Mod(module) => { + if let Some((_, nested)) = &module.content { + module_parts.push(module.ident.to_string()); + collect_item_functions(nested, module_parts, out); + module_parts.pop(); + } + } + _ => {} + } + } +} + +fn analyze_function(function: FunctionCandidate<'_>) -> Option { + let mut known_values = BTreeSet::new(); + let mut clusters: BTreeMap = BTreeMap::new(); + + for param in &function.params { + if is_usable_binding(param) { + known_values.insert(param.clone()); + clusters.insert( + param.clone(), + WorkingCluster { + name: param.clone(), + defined_line: function.line_start, + reference_lines: BTreeSet::new(), + mutation_lines: BTreeSet::new(), + connected_values: BTreeSet::new(), + }, + ); + } + } + + let mut statements = Vec::new(); + for stmt in &function.block.stmts { + let line_start = stmt.span().start().line; + let defines = collect_stmt_definitions(stmt); + let references = collect_stmt_references(stmt, &known_values); + let mutations = collect_stmt_mutations(stmt, &known_values); + for name in &defines { + if is_usable_binding(name) { + known_values.insert(name.clone()); + } + } + statements.push(StatementAccess { + line_start, + defines, + references, + mutations, + }); + } + + for statement in &statements { + let mut participants = statement.references.clone(); + participants.extend(statement.mutations.iter().cloned()); + for name in &statement.defines { + if is_usable_binding(name) { + participants.insert(name.clone()); + clusters + .entry(name.clone()) + .or_insert_with(|| WorkingCluster { + name: name.clone(), + defined_line: statement.line_start, + reference_lines: BTreeSet::new(), + mutation_lines: BTreeSet::new(), + connected_values: BTreeSet::new(), + }); + } + } + + for name in &participants { + if let Some(cluster) = clusters.get_mut(name) { + if statement.references.contains(name) { + cluster.reference_lines.insert(statement.line_start); + } + if statement.mutations.contains(name) { + cluster.mutation_lines.insert(statement.line_start); + } + for connected_value in &participants { + if connected_value != name && is_usable_binding(connected_value) { + cluster.connected_values.insert(connected_value.clone()); + } + } + } + } + } + + let mut value_clusters = clusters + .into_values() + .filter(|cluster| !cluster.reference_lines.is_empty() || !cluster.mutation_lines.is_empty()) + .map(finalize_cluster) + .collect::>(); + + value_clusters.sort_by(|a, b| { + b.reference_lines + .len() + .cmp(&a.reference_lines.len()) + .then_with(|| b.mutation_lines.len().cmp(&a.mutation_lines.len())) + .then_with(|| b.connected_values.len().cmp(&a.connected_values.len())) + .then_with(|| a.sort_line.cmp(&b.sort_line)) + .then_with(|| a.name.cmp(&b.name)) + }); + + if value_clusters.is_empty() { + return None; + } + + let reference_count = value_clusters + .iter() + .map(|cluster| cluster.reference_lines.len()) + .sum(); + let mutation_count = value_clusters + .iter() + .map(|cluster| cluster.mutation_lines.len()) + .sum(); + Some(FunctionClusterReport { + name: function.name, + line_start: function.line_start, + line_end: function.line_end, + is_test: function.is_test, + reference_count, + mutation_count, + clusters: value_clusters, + }) +} + +fn finalize_cluster(cluster: WorkingCluster) -> ValueCluster { + ValueCluster { + name: cluster.name, + reference_lines: cluster.reference_lines.into_iter().collect(), + mutation_lines: cluster.mutation_lines.into_iter().collect(), + connected_values: cluster.connected_values.into_iter().collect(), + sort_line: cluster.defined_line, + } +} + +fn collect_fn_params( + inputs: &syn::punctuated::Punctuated, +) -> Vec { + let mut params = Vec::new(); + for input in inputs { + if let FnArg::Typed(pat_type) = input { + collect_pat_idents(&pat_type.pat, &mut params); + } + } + params +} + +fn collect_stmt_definitions(stmt: &Stmt) -> Vec { + let mut out = Vec::new(); + if let Stmt::Local(local) = stmt { + collect_pat_idents(&local.pat, &mut out); + } + out.sort(); + out.dedup(); + out +} + +fn collect_stmt_references(stmt: &Stmt, known_values: &BTreeSet) -> BTreeSet { + let mut collector = ReferenceCollector { + known_values, + references: BTreeSet::new(), + }; + collector.visit_stmt(stmt); + collector.references +} + +fn collect_stmt_mutations(stmt: &Stmt, known_values: &BTreeSet) -> BTreeSet { + let mut collector = MutationCollector { + known_values, + mutations: BTreeSet::new(), + }; + collector.visit_stmt(stmt); + collector.mutations +} + +struct ReferenceCollector<'a> { + known_values: &'a BTreeSet, + references: BTreeSet, +} + +impl<'ast> Visit<'ast> for ReferenceCollector<'_> { + fn visit_expr_path(&mut self, node: &'ast ExprPath) { + if node.qself.is_none() && node.path.segments.len() == 1 { + let ident = node.path.segments[0].ident.to_string(); + if self.known_values.contains(&ident) { + self.references.insert(ident); + } + } + visit::visit_expr_path(self, node); + } + + fn visit_expr_assign(&mut self, node: &'ast syn::ExprAssign) { + self.visit_expr(&node.right); + } +} + +struct MutationCollector<'a> { + known_values: &'a BTreeSet, + mutations: BTreeSet, +} + +impl<'ast> Visit<'ast> for MutationCollector<'_> { + fn visit_expr_assign(&mut self, node: &'ast syn::ExprAssign) { + self.record_known_root(&node.left); + self.visit_expr(&node.right); + } + + fn visit_expr_binary(&mut self, node: &'ast syn::ExprBinary) { + if is_assign_binop(&node.op) { + self.record_known_root(&node.left); + } + visit::visit_expr_binary(self, node); + } + + fn visit_expr_reference(&mut self, node: &'ast syn::ExprReference) { + if node.mutability.is_some() { + self.record_known_root(&node.expr); + } + visit::visit_expr_reference(self, node); + } +} + +impl MutationCollector<'_> { + fn record_known_root(&mut self, expr: &Expr) { + if let Some(name) = root_ident(expr) { + if self.known_values.contains(&name) { + self.mutations.insert(name); + } + } + } +} + +fn root_ident(expr: &Expr) -> Option { + match expr { + Expr::Path(path) if path.qself.is_none() && path.path.segments.len() == 1 => { + Some(path.path.segments[0].ident.to_string()) + } + Expr::Field(field) => root_ident(&field.base), + Expr::Index(index) => root_ident(&index.expr), + Expr::Paren(paren) => root_ident(&paren.expr), + Expr::Group(group) => root_ident(&group.expr), + Expr::Reference(reference) => root_ident(&reference.expr), + Expr::Unary(unary) => root_ident(&unary.expr), + _ => None, + } +} + +fn is_assign_binop(op: &BinOp) -> bool { + matches!( + op, + BinOp::AddAssign(_) + | BinOp::SubAssign(_) + | BinOp::MulAssign(_) + | BinOp::DivAssign(_) + | BinOp::RemAssign(_) + | BinOp::BitXorAssign(_) + | BinOp::BitAndAssign(_) + | BinOp::BitOrAssign(_) + | BinOp::ShlAssign(_) + | BinOp::ShrAssign(_) + ) +} + +fn collect_pat_idents(pat: &Pat, out: &mut Vec) { + match pat { + Pat::Ident(ident) => { + let name = ident.ident.to_string(); + if is_usable_binding(&name) { + out.push(name); + } + if let Some((_, subpat)) = &ident.subpat { + collect_pat_idents(subpat, out); + } + } + Pat::Reference(reference) => collect_pat_idents(&reference.pat, out), + Pat::Slice(slice) => { + for elem in &slice.elems { + collect_pat_idents(elem, out); + } + } + Pat::Struct(strukt) => { + for field in &strukt.fields { + collect_pat_idents(&field.pat, out); + } + } + Pat::Tuple(tuple) => { + for elem in &tuple.elems { + collect_pat_idents(elem, out); + } + } + Pat::TupleStruct(tuple) => { + for elem in &tuple.elems { + collect_pat_idents(elem, out); + } + } + Pat::Type(typed) => collect_pat_idents(&typed.pat, out), + Pat::Or(or) => { + for case in &or.cases { + collect_pat_idents(case, out); + } + } + _ => {} + } +} + +fn is_usable_binding(name: &str) -> bool { + name != "_" && !name.starts_with('_') && name != "self" +} + +fn has_attr_named(attrs: &[syn::Attribute], wanted: &str) -> bool { + attrs.iter().any(|attr| attr.path().is_ident(wanted)) +} + +fn qualify_name(parts: &[String], name: &str) -> String { + if parts.is_empty() { + name.to_string() + } else { + format!("{}::{}", parts.join("::"), name) + } +} + +fn type_name(ty: &syn::Type) -> String { + match ty { + syn::Type::Path(path) => path + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::>() + .join("::"), + syn::Type::Reference(reference) => type_name(&reference.elem), + _ => "impl".to_string(), + } +} + +fn relative_path_string(project_path: &Path, path: &Path) -> String { + path.strip_prefix(project_path) + .unwrap_or(path) + .display() + .to_string() +} diff --git a/src/coverage.rs b/src/coverage.rs new file mode 100644 index 0000000..86c286d --- /dev/null +++ b/src/coverage.rs @@ -0,0 +1,1297 @@ +use colored::Colorize; +use serde::Serialize; +use serde_json::Value; +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::{ast_parser, datapaths, file_metrics, flow, loc, render}; + +#[derive(Debug, Clone, Serialize)] +pub struct CoverageReport { + pub cstat_version: String, + pub project: String, + pub target: String, + pub dynamic_line_coverage: DynamicLineCoverage, + pub branch_coverage: BranchCoverage, + pub static_reachability: Option, + pub projected_line_reachability: Option, + pub tool_status: ToolStatus, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DynamicLineCoverage { + pub coverable_lines: usize, + pub executed_lines: usize, + pub missing_lines: usize, + pub executed_line_numbers: Vec, + pub missing_line_numbers: Vec, + pub percent: f64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BranchCoverage { + pub total_branch_outcomes: usize, + pub covered_branch_outcomes: usize, + pub missing_branch_outcomes: usize, + pub percent: f64, + pub sites: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BranchSite { + pub line_start: usize, + pub column_start: usize, + pub line_end: usize, + pub column_end: usize, + pub expression: String, + pub true_count: u64, + pub false_count: u64, + pub taken: Vec, + pub missing: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ToolStatus { + pub ran_tests: bool, + pub llvm_cov: Option, + pub llvm_profdata: Option, + pub profile_dir: Option, + pub export_source: String, +} + +pub fn render_coverage( + rs_files: &[PathBuf], + project_path: &Path, + selected_file: Option<&Path>, + no_run: bool, + json: bool, +) -> Result<(), String> { + let symbols = ast_parser::parse_project(rs_files); + let graph = flow::build_call_graph(rs_files, project_path); + let filter = file_metrics::production_filter_for_files( + &symbols, + &graph, + project_path, + rs_files, + selected_file, + ); + + let (export_json, mut status) = if no_run { + let export_path = std::env::var("CSTAT_LLVM_COV_EXPORT_JSON").map_err(|_| { + "CSTAT_LLVM_COV_EXPORT_JSON is required when coverage --no-run is used".to_string() + })?; + let content = std::fs::read_to_string(&export_path) + .map_err(|e| format!("failed to read CSTAT_LLVM_COV_EXPORT_JSON={export_path}: {e}"))?; + ( + content, + ToolStatus { + ran_tests: false, + llvm_cov: None, + llvm_profdata: None, + profile_dir: None, + export_source: export_path, + }, + ) + } else { + collect_coverage_export(project_path, selected_file)? + }; + + let mut report = analyze_export_json(&export_json, project_path, selected_file, &filter)?; + report.tool_status = status.clone(); + + if let Some(file) = selected_file { + let file_display_path = relative_path_string(project_path, file); + let file_functions = + file_metrics::selected_file_functions(&symbols, &graph, project_path, file); + let static_reachability = datapaths::analyze_file_static_reachability( + &graph, + &file_functions, + &file_display_path, + ); + let reachable: std::collections::HashSet = static_reachability + .reachable_functions + .iter() + .cloned() + .collect(); + let projected = loc::analyze_file_projected_line_reachability( + file, + &file_functions, + &reachable, + &file_display_path, + ); + report.static_reachability = Some(static_reachability); + report.projected_line_reachability = projected; + } + + status = report.tool_status.clone(); + if json { + println!( + "{}", + serde_json::to_string(&report).map_err(|e| e.to_string())? + ); + } else { + render_coverage_report(&report, selected_file.is_some(), &status); + } + Ok(()) +} + +pub fn analyze_export_json( + export_json: &str, + project_path: &Path, + selected_file: Option<&Path>, + production_filter: &file_metrics::ProductionFilter, +) -> Result { + let value: Value = + serde_json::from_str(export_json).map_err(|e| format!("invalid llvm-cov JSON: {e}"))?; + let mut coverable = BTreeSet::new(); + let mut executed = BTreeSet::new(); + let mut sites = Vec::new(); + let mut branch_data_present = false; + let mut source_cache: HashMap> = HashMap::new(); + + for data in value + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + for file_obj in data + .get("files") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(filename) = file_obj.get("filename").and_then(Value::as_str) else { + continue; + }; + let Some(source_file) = match_export_file(filename, production_filter) else { + continue; + }; + if selected_file.is_some_and(|selected| !same_path_or_suffix(selected, &source_file)) { + continue; + } + + if let Some(segments) = file_obj.get("segments").and_then(Value::as_array) { + for segment in segments { + let Some(array) = segment.as_array() else { + continue; + }; + let line = array.first().and_then(Value::as_u64).unwrap_or(0) as usize; + let count = array.get(2).and_then(Value::as_u64).unwrap_or(0); + let has_count = array.get(3).and_then(Value::as_bool).unwrap_or(false); + let is_gap = array.get(5).and_then(Value::as_bool).unwrap_or(false); + if line == 0 + || is_gap + || !has_count + || !production_filter.includes_line(&source_file, line) + { + continue; + } + coverable.insert((source_file.clone(), line)); + if count > 0 { + executed.insert((source_file.clone(), line)); + } + } + } + + if let Some(branches) = file_obj.get("branches").and_then(Value::as_array) { + branch_data_present = true; + for branch in branches { + let Some(array) = branch.as_array() else { + continue; + }; + let line_start = array.first().and_then(Value::as_u64).unwrap_or(0) as usize; + let column_start = array.get(1).and_then(Value::as_u64).unwrap_or(0) as usize; + let line_end = array.get(2).and_then(Value::as_u64).unwrap_or(0) as usize; + let column_end = array.get(3).and_then(Value::as_u64).unwrap_or(0) as usize; + let true_count = array.get(4).and_then(Value::as_u64).unwrap_or(0); + let false_count = array.get(5).and_then(Value::as_u64).unwrap_or(0); + if line_start == 0 || !production_filter.includes_line(&source_file, line_start) + { + continue; + } + let expression = branch_expression( + &source_file, + line_start, + column_start, + line_end, + column_end, + &mut source_cache, + ); + let mut taken = Vec::new(); + let mut missing = Vec::new(); + if true_count > 0 { + taken.push("true".to_string()); + } else { + missing.push("true".to_string()); + } + if false_count > 0 { + taken.push("false".to_string()); + } else { + missing.push("false".to_string()); + } + sites.push(BranchSite { + line_start, + column_start, + line_end, + column_end, + expression, + true_count, + false_count, + taken, + missing, + }); + } + } + } + } + + let executed_line_numbers = line_numbers(&executed); + let missing_set: BTreeSet<(PathBuf, usize)> = + coverable.difference(&executed).cloned().collect(); + let missing_line_numbers = line_numbers(&missing_set); + let coverable_lines = coverable.len(); + let executed_lines = executed.len(); + let missing_lines = coverable_lines.saturating_sub(executed_lines); + let line_percent = percent(executed_lines, coverable_lines); + + sites.sort_by(|a, b| { + a.line_start + .cmp(&b.line_start) + .then(a.column_start.cmp(&b.column_start)) + }); + let total_branch_outcomes = if branch_data_present { + sites.len() * 2 + } else { + 0 + }; + let covered_branch_outcomes: usize = sites.iter().map(|site| site.taken.len()).sum(); + let missing_branch_outcomes = total_branch_outcomes.saturating_sub(covered_branch_outcomes); + let branch_percent = percent(covered_branch_outcomes, total_branch_outcomes); + + Ok(CoverageReport { + cstat_version: env!("CARGO_PKG_VERSION").to_string(), + project: project_name(project_path), + target: selected_file + .map(|file| relative_path_string(project_path, file)) + .unwrap_or_else(|| "project".to_string()), + dynamic_line_coverage: DynamicLineCoverage { + coverable_lines, + executed_lines, + missing_lines, + executed_line_numbers, + missing_line_numbers, + percent: line_percent, + }, + branch_coverage: BranchCoverage { + total_branch_outcomes, + covered_branch_outcomes, + missing_branch_outcomes, + percent: branch_percent, + sites, + }, + static_reachability: None, + projected_line_reachability: None, + tool_status: ToolStatus { + ran_tests: false, + llvm_cov: None, + llvm_profdata: None, + profile_dir: None, + export_source: "provided_json".to_string(), + }, + }) +} + +fn render_coverage_report(report: &CoverageReport, selected_file: bool, status: &ToolStatus) { + println!( + "\n{} {}", + "cstat coverage".bright_cyan().bold(), + format!("({})", report.target).dimmed() + ); + println!(" {} {}", "source".cyan(), status.export_source); + + if selected_file { + render::section_header("Static test/benchmark reachability"); + if let Some(static_reachability) = &report.static_reachability { + datapaths::render_file_static_reachability_report(static_reachability, false); + } + render::section_header("Projected line reachability"); + loc::render_file_projected_line_reachability_report( + &report.projected_line_reachability, + false, + ); + } + + render::section_header("Dynamic line coverage"); + println!( + " executed lines {} / {} ({:.1}%) missing {}", + report + .dynamic_line_coverage + .executed_lines + .to_string() + .bold(), + report.dynamic_line_coverage.coverable_lines, + report.dynamic_line_coverage.percent, + report + .dynamic_line_coverage + .missing_lines + .to_string() + .yellow(), + ); + if !report.dynamic_line_coverage.missing_line_numbers.is_empty() { + let lines = report + .dynamic_line_coverage + .missing_line_numbers + .iter() + .take(30) + .map(|line| line.to_string()) + .collect::>() + .join(", "); + println!(" missing lines: {lines}"); + } + + render::section_header("Branch coverage"); + if report.branch_coverage.total_branch_outcomes == 0 { + println!( + " {}", + "branch data unavailable in llvm-cov export".yellow() + ); + return; + } + println!( + " branch outcomes {} / {} ({:.1}%) missing {}", + report + .branch_coverage + .covered_branch_outcomes + .to_string() + .bold(), + report.branch_coverage.total_branch_outcomes, + report.branch_coverage.percent, + report + .branch_coverage + .missing_branch_outcomes + .to_string() + .yellow(), + ); + for site in &report.branch_coverage.sites { + if site.missing.is_empty() { + continue; + } + println!( + " line {}: {} missing {} (true={}, false={})", + site.line_start, + site.expression, + site.missing.join("/"), + site.true_count, + site.false_count, + ); + } +} + +fn cargo_target_dir(project_path: &Path) -> Result { + let manifest_path = project_path.join("Cargo.toml"); + let output = Command::new("cargo") + .arg("metadata") + .arg("--format-version") + .arg("1") + .arg("--no-deps") + .arg("--manifest-path") + .arg(&manifest_path) + .output() + .map_err(|e| { + format!( + "failed to run cargo metadata for {}: {e}", + manifest_path.display() + ) + })?; + let stderr = String::from_utf8(output.stderr).map_err(|e| { + format!( + "cargo metadata for {} emitted non-utf8 stderr: {e}", + manifest_path.display() + ) + })?; + let stdout = String::from_utf8(output.stdout).map_err(|e| { + format!( + "cargo metadata for {} emitted non-utf8 stdout: {e}", + manifest_path.display() + ) + })?; + if !output.status.success() { + return Err(format!( + "cargo metadata for {} failed with status {}: {}", + manifest_path.display(), + output.status, + stderr.trim() + )); + } + target_dir_from_metadata_json(&stdout, project_path).map_err(|e| { + format!( + "cargo metadata for {} returned invalid target_directory: {e}", + manifest_path.display() + ) + }) +} + +fn target_dir_from_metadata_json( + metadata_json: &str, + project_path: &Path, +) -> Result { + let metadata: Value = serde_json::from_str(metadata_json) + .map_err(|e| format!("failed to parse cargo metadata JSON: {e}"))?; + let target_directory = metadata + .get("target_directory") + .and_then(Value::as_str) + .ok_or_else(|| "missing string field target_directory".to_string())?; + let target_dir = PathBuf::from(target_directory); + if target_dir.is_absolute() { + Ok(target_dir) + } else { + Ok(project_path.join(target_dir)) + } +} + +fn coverage_profile_dir(target_dir: &Path) -> PathBuf { + target_dir.join("cstat-coverage") +} + +fn collect_coverage_export( + project_path: &Path, + selected_file: Option<&Path>, +) -> Result<(String, ToolStatus), String> { + let target_dir = cargo_target_dir(project_path)?; + let llvm_cov = find_tool("LLVM_COV", "llvm-cov")?; + let llvm_profdata = find_tool("LLVM_PROFDATA", "llvm-profdata")?; + let profile_dir = coverage_profile_dir(&target_dir); + std::fs::create_dir_all(&profile_dir) + .map_err(|e| format!("failed to create {}: {e}", profile_dir.display()))?; + for stale_profraw in files_with_extension(&profile_dir, "profraw")? { + std::fs::remove_file(&stale_profraw) + .map_err(|e| format!("failed to remove {}: {e}", stale_profraw.display()))?; + } + let stale_profdata = profile_dir.join("cstat.profdata"); + if stale_profdata.exists() { + std::fs::remove_file(&stale_profdata) + .map_err(|e| format!("failed to remove {}: {e}", stale_profdata.display()))?; + } + + let cargo_args = cargo_test_args(project_path, selected_file); + let mut cargo = Command::new("cargo"); + cargo.args(&cargo_args); + let status = cargo + .env("RUSTFLAGS", "-C instrument-coverage -C link-dead-code") + .env( + "LLVM_PROFILE_FILE", + profile_dir + .join("%p-%m.profraw") + .to_string_lossy() + .to_string(), + ) + .status() + .map_err(|e| format!("failed to run cargo test for coverage: {e}"))?; + if !status.success() { + return Err(format!( + "cargo test for coverage failed with status {status}" + )); + } + + let profraws = files_with_extension(&profile_dir, "profraw")?; + if profraws.is_empty() { + return Err(format!( + "no profraw profiles found in {}", + profile_dir.display() + )); + } + let profdata = profile_dir.join("cstat.profdata"); + let mut merge = Command::new(&llvm_profdata); + merge.arg("merge").arg("-sparse"); + for profraw in &profraws { + merge.arg(profraw); + } + merge.arg("-o").arg(&profdata); + let status = merge + .status() + .map_err(|e| format!("failed to run llvm-profdata merge: {e}"))?; + if !status.success() { + return Err(format!("llvm-profdata merge failed with status {status}")); + } + + let binaries = discover_test_binaries(&target_dir, project_path, selected_file)?; + if binaries.is_empty() { + return Err(format!( + "no test binaries found under {}", + target_dir.join("debug/deps").display() + )); + } + let binaries = coverage_enabled_test_binaries(&llvm_cov, &profdata, &binaries, selected_file)?; + let mut export = Command::new(&llvm_cov); + export + .arg("export") + .arg("-format=text") + .arg("-instr-profile") + .arg(&profdata); + for binary in &binaries { + export.arg(binary); + } + let output = export + .output() + .map_err(|e| format!("failed to run llvm-cov export: {e}"))?; + if !output.status.success() { + return Err(format!( + "llvm-cov export failed with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + let json = String::from_utf8(output.stdout) + .map_err(|e| format!("llvm-cov export emitted non-utf8 output: {e}"))?; + Ok(( + json, + ToolStatus { + ran_tests: true, + llvm_cov: Some(llvm_cov.to_string_lossy().to_string()), + llvm_profdata: Some(llvm_profdata.to_string_lossy().to_string()), + profile_dir: Some(profile_dir.to_string_lossy().to_string()), + export_source: format_command("cargo", &cargo_args), + }, + )) +} + +fn cargo_test_args(project_path: &Path, selected_file: Option<&Path>) -> Vec { + let mut args = vec![ + "test".to_string(), + "--manifest-path".to_string(), + project_path + .join("Cargo.toml") + .to_string_lossy() + .to_string(), + "--no-fail-fast".to_string(), + ]; + if let Some(file) = selected_file { + args.extend(selected_target_args(project_path, file)); + } + args +} + +fn selected_target_args(project_path: &Path, selected_file: &Path) -> Vec { + let rel = selected_file + .strip_prefix(project_path) + .unwrap_or(selected_file); + let components: Vec = rel + .components() + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .collect(); + let stem = selected_file + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("main") + .to_string(); + + if components.len() >= 3 && components[0] == "src" && components[1] == "bin" { + return vec![ + "--bin".to_string(), + manifest_target_name(project_path, "bin", selected_file).unwrap_or(stem), + ]; + } + if components.as_slice() == ["src", "main.rs"] { + return vec![ + "--bin".to_string(), + manifest_target_name(project_path, "bin", selected_file) + .or_else(|| package_name(project_path)) + .unwrap_or(stem), + ]; + } + if components.len() >= 2 && components[0] == "tests" { + return vec![ + "--test".to_string(), + manifest_target_name(project_path, "test", selected_file).unwrap_or(stem), + ]; + } + if components.len() >= 2 && components[0] == "benches" { + return vec![ + "--bench".to_string(), + manifest_target_name(project_path, "bench", selected_file).unwrap_or(stem), + ]; + } + if components + .first() + .is_some_and(|component| component == "src") + { + return vec!["--lib".to_string()]; + } + Vec::new() +} + +fn manifest_target_name( + project_path: &Path, + section: &str, + selected_file: &Path, +) -> Option { + let manifest = std::fs::read_to_string(project_path.join("Cargo.toml")).ok()?; + let wanted_header = format!("[[{section}]]"); + let mut in_target = false; + let mut name: Option = None; + let mut path: Option = None; + + let flush = |name: &mut Option, path: &mut Option| -> Option { + let target_path = path.take()?; + let target_name = name.take()?; + let manifest_path = project_path.join(target_path); + if same_path_or_suffix(&manifest_path, selected_file) { + Some(target_name) + } else { + None + } + }; + + for line in manifest.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + if in_target { + if let Some(target_name) = flush(&mut name, &mut path) { + return Some(target_name); + } + } + in_target = trimmed == wanted_header; + name = None; + path = None; + continue; + } + if !in_target { + continue; + } + if let Some((key, value)) = trimmed.split_once('=') { + match key.trim() { + "name" => name = quoted_toml_value(value), + "path" => path = quoted_toml_value(value), + _ => {} + } + } + } + if in_target { + flush(&mut name, &mut path) + } else { + None + } +} + +fn quoted_toml_value(raw: &str) -> Option { + let value = raw.trim().split('#').next()?.trim(); + value + .strip_prefix('"') + .and_then(|value| value.split_once('"').map(|(inner, _)| inner.to_string())) +} + +fn format_command(program: &str, args: &[String]) -> String { + std::iter::once(program.to_string()) + .chain(args.iter().map(|arg| { + if arg.contains(char::is_whitespace) { + format!("{arg:?}") + } else { + arg.clone() + } + })) + .collect::>() + .join(" ") +} + +fn find_tool(env_key: &str, binary: &str) -> Result { + if let Some(value) = std::env::var_os(env_key) { + let path = PathBuf::from(value); + if path.exists() { + return Ok(path); + } + } + if let Some(paths) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&paths) { + let candidate = dir.join(binary); + if candidate.exists() { + return Ok(candidate); + } + } + } + if let (Ok(sysroot), Ok(host)) = (rustc_sysroot(), rustc_host()) { + let candidate = Path::new(sysroot.trim()) + .join("lib/rustlib") + .join(host.trim()) + .join("bin") + .join(binary); + if candidate.exists() { + return Ok(candidate); + } + } + Err("LLVM coverage tools unavailable; install rustup component llvm-tools-preview or set LLVM_COV and LLVM_PROFDATA".to_string()) +} + +fn rustc_sysroot() -> Result { + let output = Command::new("rustc") + .arg("--print") + .arg("sysroot") + .output() + .map_err(|e| e.to_string())?; + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn rustc_host() -> Result { + let output = Command::new("rustc") + .arg("-vV") + .output() + .map_err(|e| e.to_string())?; + let text = String::from_utf8_lossy(&output.stdout); + text.lines() + .find_map(|line| line.strip_prefix("host: ").map(str::to_string)) + .ok_or_else(|| "rustc -vV did not report host".to_string()) +} + +fn files_with_extension(dir: &Path, extension: &str) -> Result, String> { + let mut out = Vec::new(); + for entry in + std::fs::read_dir(dir).map_err(|e| format!("failed to read {}: {e}", dir.display()))? + { + let path = entry.map_err(|e| e.to_string())?.path(); + if path.extension().is_some_and(|ext| ext == extension) { + out.push(path); + } + } + Ok(out) +} + +fn discover_test_binaries( + target_dir: &Path, + project_path: &Path, + selected_file: Option<&Path>, +) -> Result, String> { + let deps = target_dir.join("debug/deps"); + let selected_prefix = + selected_file.and_then(|file| selected_test_binary_prefix(project_path, file)); + let package_prefix = package_name(project_path).map(|name| name.replace('-', "_")); + let mut fallback = Vec::new(); + let mut package_preferred = Vec::new(); + let mut selected_preferred = Vec::new(); + for entry in + std::fs::read_dir(&deps).map_err(|e| format!("failed to read {}: {e}", deps.display()))? + { + let path = entry.map_err(|e| e.to_string())?.path(); + if !path.is_file() || !is_executable(&path) { + continue; + } + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + if selected_prefix + .as_ref() + .is_some_and(|prefix| name.starts_with(prefix)) + { + selected_preferred.push(path.clone()); + } + if package_prefix + .as_ref() + .is_some_and(|prefix| name.starts_with(prefix)) + { + package_preferred.push(path.clone()); + } + fallback.push(path); + } + if !selected_preferred.is_empty() { + Ok(selected_preferred) + } else if !package_preferred.is_empty() { + Ok(package_preferred) + } else { + Ok(fallback) + } +} + +fn selected_test_binary_prefix(project_path: &Path, selected_file: &Path) -> Option { + let args = selected_target_args(project_path, selected_file); + let target_name = match args.as_slice() { + [kind] if kind == "--lib" => package_name(project_path)?, + [kind, name] if matches!(kind.as_str(), "--bin" | "--test" | "--bench") => name.clone(), + _ => return None, + }; + Some(target_name.replace('-', "_")) +} + +fn coverage_enabled_test_binaries( + llvm_cov: &Path, + profdata: &Path, + binaries: &[PathBuf], + selected_file: Option<&Path>, +) -> Result, String> { + let mut covered = Vec::new(); + let mut selected_scores = Vec::new(); + let mut failures = Vec::new(); + for binary in binaries { + let output = Command::new(llvm_cov) + .arg("export") + .arg("-summary-only") + .arg("-format=text") + .arg("-instr-profile") + .arg(profdata) + .arg(binary) + .output() + .map_err(|e| format!("failed to run llvm-cov export probe: {e}"))?; + if output.status.success() { + if let Some(selected) = selected_file { + let score = selected_file_covered_score(&output.stdout, selected); + if score > 0 { + selected_scores.push((binary.clone(), score)); + } else { + failures.push(format!( + "{}: no execution data for {}", + binary.display(), + selected.display() + )); + } + } else { + covered.push(binary.clone()); + } + } else { + failures.push(format!( + "{}: {}", + binary.display(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + } + if selected_file.is_some() && !selected_scores.is_empty() { + let best_score = selected_scores + .iter() + .map(|(_, score)| *score) + .max() + .unwrap_or(0); + return Ok(selected_scores + .into_iter() + .filter_map(|(binary, score)| (score == best_score).then_some(binary)) + .collect()); + } + if covered.is_empty() { + Err(format!( + "no coverage-enabled test binaries found among {} candidate(s): {}", + binaries.len(), + failures.join("; ") + )) + } else { + Ok(covered) + } +} + +fn selected_file_covered_score(export_json: &[u8], selected_file: &Path) -> usize { + let Ok(value) = serde_json::from_slice::(export_json) else { + return 0; + }; + value + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .flat_map(|data| { + data.get("files") + .and_then(Value::as_array) + .into_iter() + .flatten() + }) + .filter_map(|file_obj| { + let filename = file_obj.get("filename").and_then(Value::as_str)?; + let source_file = PathBuf::from(filename); + same_path_or_suffix(selected_file, &source_file).then_some(file_obj) + }) + .map(|file_obj| { + file_obj + .pointer("/summary/lines/covered") + .and_then(Value::as_u64) + .map(|covered| covered as usize) + .unwrap_or_else(|| selected_file_segment_score(file_obj)) + }) + .sum() +} + +fn selected_file_segment_score(file_obj: &Value) -> usize { + file_obj + .get("segments") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|segment| { + let Some(array) = segment.as_array() else { + return false; + }; + let count = array.get(2).and_then(Value::as_u64).unwrap_or(0); + let has_count = array.get(3).and_then(Value::as_bool).unwrap_or(false); + let is_gap = array.get(5).and_then(Value::as_bool).unwrap_or(false); + count > 0 && has_count && !is_gap + }) + .count() +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + path.metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable(path: &Path) -> bool { + path.extension().is_none() +} + +fn package_name(project_path: &Path) -> Option { + let manifest = std::fs::read_to_string(project_path.join("Cargo.toml")).ok()?; + let mut in_package = false; + for line in manifest.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_package = trimmed == "[package]"; + continue; + } + if in_package { + if let Some(value) = trimmed.strip_prefix("name") { + if let Some((_, raw_name)) = value.split_once('=') { + let name = raw_name.trim().trim_matches('"'); + if !name.is_empty() { + return Some(name.to_string()); + } + } + } + } + } + None +} + +fn match_export_file(filename: &str, filter: &file_metrics::ProductionFilter) -> Option { + let candidate = PathBuf::from(filename); + for entry in &filter.files { + if same_path_or_suffix(&entry.file, &candidate) { + return Some(entry.file.clone()); + } + } + None +} + +fn same_path_or_suffix(a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + if let (Ok(a), Ok(b)) = (a.canonicalize(), b.canonicalize()) { + if a == b { + return true; + } + } + let a = normalize_path(a); + let b = normalize_path(b); + a.ends_with(&b) || b.ends_with(&a) +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +fn branch_expression( + source_file: &Path, + line_start: usize, + column_start: usize, + line_end: usize, + column_end: usize, + cache: &mut HashMap>, +) -> String { + let lines = cache.entry(source_file.to_path_buf()).or_insert_with(|| { + std::fs::read_to_string(source_file) + .map(|source| source.lines().map(str::to_string).collect()) + .unwrap_or_default() + }); + let Some(line) = lines.get(line_start.saturating_sub(1)) else { + return String::new(); + }; + if line_start == line_end && column_start > 0 && column_end > column_start { + let start = column_start.saturating_sub(1); + let len = column_end.saturating_sub(column_start); + let slice: String = line.chars().skip(start).take(len).collect(); + let trimmed = slice.trim(); + if !trimmed.is_empty() { + return trimmed.to_string(); + } + } + line.trim().to_string() +} + +fn line_numbers(lines: &BTreeSet<(PathBuf, usize)>) -> Vec { + let mut numbers: Vec = lines.iter().map(|(_, line)| *line).collect(); + numbers.sort_unstable(); + numbers.dedup(); + numbers +} + +fn percent(part: usize, total: usize) -> f64 { + if total == 0 { + 0.0 + } else { + part as f64 / total as f64 * 100.0 + } +} + +fn project_name(project_path: &Path) -> String { + package_name(project_path).unwrap_or_else(|| { + project_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()) + }) +} + +fn relative_path_string(project_path: &Path, path: &Path) -> String { + path.strip_prefix(project_path) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_project(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("cstat-coverage-{name}-{unique}")); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + root + } + + fn write_executable(path: &Path) { + fs::write(path, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); + } + } + + #[test] + fn coverage_target_dir_parses_metadata_target_directory() { + let root = temp_project("metadata-parse"); + let target = root.join("workspace-target"); + let json = format!( + r#"{{"target_directory":"{}"}}"#, + target.to_string_lossy().replace('\\', "\\\\") + ); + + assert_eq!(target_dir_from_metadata_json(&json, &root).unwrap(), target); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn coverage_target_dir_rejects_missing_or_malformed_metadata_target_directory() { + let root = temp_project("metadata-reject"); + + assert!(target_dir_from_metadata_json("{}", &root).is_err()); + assert!(target_dir_from_metadata_json(r#"{"target_directory":42}"#, &root).is_err()); + assert!(target_dir_from_metadata_json("not json", &root).is_err()); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn coverage_target_dir_profile_dir_uses_effective_target() { + let root = temp_project("profile-dir"); + let custom_target = root.with_extension("custom-target"); + + assert_eq!( + coverage_profile_dir(&custom_target), + custom_target.join("cstat-coverage") + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn coverage_target_dir_discovers_binaries_from_effective_target_and_ignores_project_target() { + let root = temp_project("binary-effective"); + let custom_target = root.with_extension("custom-target"); + let stale_deps = root.join("target/debug/deps"); + let custom_deps = custom_target.join("debug/deps"); + fs::create_dir_all(&stale_deps).unwrap(); + fs::create_dir_all(&custom_deps).unwrap(); + write_executable(&stale_deps.join("fixture-stale")); + write_executable(&custom_deps.join("fixture-abc")); + write_executable(&custom_deps.join("other-def")); + + let binaries = discover_test_binaries(&custom_target, &root, None).unwrap(); + let names: Vec<_> = binaries + .iter() + .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) + .collect(); + + assert_eq!(names, vec!["fixture-abc"]); + assert!(!binaries.iter().any(|path| path.ends_with("fixture-stale"))); + + fs::remove_dir_all(custom_target).unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn coverage_target_dir_falls_back_to_all_executables_without_package_prefix() { + let root = temp_project("binary-fallback"); + let custom_target = root.with_extension("custom-target"); + let custom_deps = custom_target.join("debug/deps"); + fs::create_dir_all(&custom_deps).unwrap(); + write_executable(&custom_deps.join("alpha")); + write_executable(&custom_deps.join("beta")); + + let binaries = discover_test_binaries(&custom_target, &root, None).unwrap(); + let names: BTreeSet<_> = binaries + .iter() + .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) + .collect(); + + assert_eq!( + names, + ["alpha".to_string(), "beta".to_string()] + .into_iter() + .collect() + ); + + fs::remove_dir_all(custom_target).unwrap(); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn coverage_target_dir_prefers_selected_binary_target_over_package_prefix() { + let root = temp_project("binary-selected"); + fs::create_dir_all(root.join("src/bin")).unwrap(); + fs::write(root.join("src/bin/mvp_chat.rs"), "fn main() {}\n").unwrap(); + fs::write( + root.join("Cargo.toml"), + r#"[package] +name = "mvp-system" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "mvp-chat" +path = "src/bin/mvp_chat.rs" +"#, + ) + .unwrap(); + let custom_target = root.with_extension("custom-target"); + let custom_deps = custom_target.join("debug/deps"); + fs::create_dir_all(&custom_deps).unwrap(); + write_executable(&custom_deps.join("mvp_system-stale")); + write_executable(&custom_deps.join("mvp_chat-current")); + + let binaries = discover_test_binaries( + &custom_target, + &root, + Some(&root.join("src/bin/mvp_chat.rs")), + ) + .unwrap(); + let names: Vec<_> = binaries + .iter() + .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) + .collect(); + + assert_eq!(names, vec!["mvp_chat-current"]); + + fs::remove_dir_all(custom_target).unwrap(); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn coverage_target_dir_error_mentions_effective_deps_dir() { + let root = temp_project("binary-error"); + let custom_target = root.with_extension("custom-target"); + + let err = discover_test_binaries(&custom_target, &root, None).unwrap_err(); + + assert!( + err.contains(&custom_target.join("debug/deps").display().to_string()), + "{err}" + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn parses_llvm_export_line_and_branch_sites() { + let root = temp_project("parser"); + let source = root.join("src/lib.rs"); + fs::write( + &source, + "pub fn decide(flag: bool) -> u8 {\n if flag { 1 } else { 0 }\n}\n", + ) + .unwrap(); + let symbols = ast_parser::parse_project(&[source.clone()]); + let graph = flow::build_call_graph(&[source.clone()], &root); + let filter = file_metrics::production_filter_for_files( + &symbols, + &graph, + &root, + &[source.clone()], + Some(&source), + ); + let json = format!( + r#"{{"type":"llvm.coverage.json.export","data":[{{"files":[{{"filename":"{}","segments":[[1,1,1,true,true,false],[2,5,1,true,true,false],[3,1,0,true,true,false]],"branches":[[2,8,2,12,1,0,0,0,0]],"summary":{{}}}}]}}]}}"#, + source.to_string_lossy().replace('\\', "\\\\") + ); + let report = analyze_export_json(&json, &root, Some(&source), &filter).unwrap(); + assert_eq!(report.branch_coverage.sites.len(), 1); + let site = &report.branch_coverage.sites[0]; + assert_eq!(site.line_start, 2); + assert_eq!(site.true_count, 1); + assert_eq!(site.false_count, 0); + assert_eq!(site.taken, vec!["true"]); + assert_eq!(site.missing, vec!["false"]); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn selected_coverage_target_infers_lib_bin_and_test_args() { + let root = temp_project("targets"); + fs::create_dir_all(root.join("src/bin")).unwrap(); + fs::create_dir_all(root.join("tests")).unwrap(); + fs::write(root.join("src/lib.rs"), "pub fn lib() {}\n").unwrap(); + fs::write(root.join("src/bin/mvp_chat.rs"), "fn main() {}\n").unwrap(); + fs::write(root.join("tests/api.rs"), "#[test] fn api() {}\n").unwrap(); + fs::write( + root.join("Cargo.toml"), + r#"[package] +name = "fixture" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "mvp-chat" +path = "src/bin/mvp_chat.rs" +"#, + ) + .unwrap(); + + let lib_args = cargo_test_args(&root, Some(&root.join("src/lib.rs"))); + assert!(lib_args.ends_with(&["--lib".to_string()]), "{lib_args:?}"); + let bin_args = cargo_test_args(&root, Some(&root.join("src/bin/mvp_chat.rs"))); + assert!( + bin_args.ends_with(&["--bin".to_string(), "mvp-chat".to_string()]), + "{bin_args:?}" + ); + let test_args = cargo_test_args(&root, Some(&root.join("tests/api.rs"))); + assert!( + test_args.ends_with(&["--test".to_string(), "api".to_string()]), + "{test_args:?}" + ); + + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/datapaths.rs b/src/datapaths.rs index ead6fcb..00be5d9 100644 --- a/src/datapaths.rs +++ b/src/datapaths.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::path::PathBuf; use crate::flow::{self, build_call_graph, CallGraph, EntryPointKind}; -use crate::render; +use crate::{ast_parser, file_metrics, render}; #[derive(Debug, Clone, Serialize)] pub struct TestReachabilityEntry { @@ -43,6 +43,37 @@ pub struct TestReachabilityAnalysis { pub analysis_note: String, } +#[derive(Debug, Clone, Serialize)] +pub struct FileStaticReachabilityEntry { + pub entry: String, + pub kind: String, + pub reachable_functions: usize, + pub unique_call_steps: usize, + pub max_call_depth: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileStaticReachabilityEdge { + pub caller: String, + pub callee: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileStaticReachabilityReport { + pub file: String, + pub test_benchmark_entry_count: usize, + pub production_function_count: usize, + pub reachable_function_count: usize, + pub unreachable_function_count: usize, + pub local_edge_count: usize, + pub reachable_local_edge_count: usize, + pub entries: Vec, + pub reachable_functions: Vec, + pub unreachable_functions: Vec, + pub reachable_edges: Vec, + pub wrapper_entrypoints: Vec, +} + pub fn analyze_test_reachability( rs_files: &[PathBuf], project_path: &Path, @@ -152,6 +183,126 @@ pub fn analyze_graph_test_reachability(graph: &CallGraph) -> TestReachabilityAna } } +pub fn analyze_file_static_reachability( + graph: &CallGraph, + file_functions: &file_metrics::FileFunctionIndex, + file_display_path: &str, +) -> FileStaticReachabilityReport { + let selected: HashSet = file_functions + .functions + .iter() + .map(|function| function.graph_name.clone()) + .collect(); + let production: HashSet = file_functions + .functions + .iter() + .filter(|function| { + !function.is_test + && !function.is_bench + && !function.is_test_support + && !function.is_binary_wrapper_main + }) + .map(|function| function.graph_name.clone()) + .collect(); + let mut wrapper_entrypoints: Vec = file_functions + .functions + .iter() + .filter(|function| function.is_binary_wrapper_main) + .map(|function| function.display_name.clone()) + .collect(); + wrapper_entrypoints.sort(); + + let mut roots = flow::entry_points_by_kind(graph, EntryPointKind::Test); + roots.extend(flow::entry_points_by_kind(graph, EntryPointKind::Bench)); + roots.sort(); + roots.dedup(); + + let mut reachable_functions_set: HashSet = HashSet::new(); + let mut reachable_edges_set: HashSet<(String, String)> = HashSet::new(); + let mut entries = Vec::new(); + + for root in &roots { + let (reachable, edges) = reachable_subgraph(graph, root); + let root_reachable_functions = reachable + .iter() + .filter(|function| production.contains(*function)) + .count(); + let root_reachable_edges = edges + .iter() + .filter(|(caller, callee)| selected.contains(caller) && selected.contains(callee)) + .count(); + + reachable_functions_set.extend( + reachable + .iter() + .filter(|function| production.contains(*function)) + .cloned(), + ); + reachable_edges_set.extend( + edges + .iter() + .filter(|(caller, callee)| selected.contains(caller) && selected.contains(callee)) + .cloned(), + ); + + let kind = graph + .entry_point_kinds + .get(root) + .map(|kind| kind.as_str().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + entries.push(FileStaticReachabilityEntry { + entry: root.clone(), + kind, + reachable_functions: root_reachable_functions, + unique_call_steps: root_reachable_edges, + max_call_depth: max_depth_from(graph, root, &mut HashSet::new()), + }); + } + + entries.sort_by(|a, b| a.entry.cmp(&b.entry)); + + let mut reachable_functions: Vec = reachable_functions_set.into_iter().collect(); + reachable_functions.sort(); + let mut unreachable_functions: Vec = production + .iter() + .filter(|function| !reachable_functions.contains(function)) + .cloned() + .collect(); + unreachable_functions.sort(); + + let local_edge_count = graph + .adjacency + .iter() + .map(|(caller, callees)| { + callees + .iter() + .filter(|callee| selected.contains(caller) && selected.contains(*callee)) + .count() + }) + .sum(); + + let mut reachable_edges: Vec = reachable_edges_set + .into_iter() + .map(|(caller, callee)| FileStaticReachabilityEdge { caller, callee }) + .collect(); + reachable_edges.sort_by(|a, b| a.caller.cmp(&b.caller).then(a.callee.cmp(&b.callee))); + + FileStaticReachabilityReport { + file: file_display_path.to_string(), + test_benchmark_entry_count: entries.len(), + production_function_count: production.len(), + reachable_function_count: reachable_functions.len(), + unreachable_function_count: unreachable_functions.len(), + local_edge_count, + reachable_local_edge_count: reachable_edges.len(), + entries, + reachable_functions, + unreachable_functions, + reachable_edges, + wrapper_entrypoints, + } +} + fn reachable_subgraph( graph: &CallGraph, entry: &str, @@ -296,6 +447,147 @@ pub fn render_test_reachability_json(rs_files: &[PathBuf], project_path: &Path) println!("{}", serde_json::to_string(&report).unwrap()); } +pub fn render_test_reachability_file( + rs_files: &[PathBuf], + project_path: &Path, + file: &Path, + verbose: bool, +) { + let report = analyze_file_static_reachability_from_files(rs_files, project_path, file); + render_file_static_reachability_report(&report, verbose); +} + +pub fn render_test_reachability_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) { + let report = analyze_file_static_reachability_from_files(rs_files, project_path, file); + println!("{}", serde_json::to_string(&report).unwrap()); +} + +pub fn analyze_file_static_reachability_from_files( + rs_files: &[PathBuf], + project_path: &Path, + file: &Path, +) -> FileStaticReachabilityReport { + let symbols = ast_parser::parse_project(rs_files); + let graph = build_call_graph(rs_files, project_path); + let file_functions = + file_metrics::selected_file_functions(&symbols, &graph, project_path, file); + let file_display_path = relative_path_string(project_path, file); + analyze_file_static_reachability(&graph, &file_functions, &file_display_path) +} + +pub fn render_file_static_reachability_report( + report: &FileStaticReachabilityReport, + verbose: bool, +) { + render::section_header("Static test/benchmark reachability"); + + if verbose { + render::verbose_block(&[ + "Roots are tests and benchmarks discovered anywhere in the project.", + "Reachability is computed from the static call graph; it is not runtime coverage.", + "Production counts exclude tests, test-support helpers, benches, and selected binary wrapper main().", + ]); + } + + let pct = if report.production_function_count == 0 { + 0.0 + } else { + report.reachable_function_count as f64 / report.production_function_count as f64 * 100.0 + }; + println!( + " {} {} {} {} / {} ({:.1}%) {} {} / {}", + "test/bench roots".cyan(), + report.test_benchmark_entry_count.to_string().bold(), + "functions reachable".cyan(), + report.reachable_function_count.to_string().bold(), + report.production_function_count, + pct, + "local edges reached".cyan(), + report.reachable_local_edge_count.to_string().bold(), + report.local_edge_count, + ); + + if !report.wrapper_entrypoints.is_empty() { + println!( + " {} {}", + "wrapper entrypoints:".cyan(), + report.wrapper_entrypoints.join(", ") + ); + } + + let visible_entries: Vec<&FileStaticReachabilityEntry> = report + .entries + .iter() + .filter(|entry| entry.reachable_functions > 0 || entry.unique_call_steps > 0) + .collect(); + + println!(); + println!( + " {:<48} {:<7} {:>9} {:>7} {:>8}", + "entry root".bold(), + "kind".bold(), + "functions".bold(), + "steps".bold(), + "depth".bold(), + ); + if report.entries.is_empty() { + println!(" {}", "No project test or benchmark roots found.".yellow()); + } else if visible_entries.is_empty() { + println!( + " {}", + "No project test or benchmark roots reach selected production functions.".yellow() + ); + } else { + for entry in visible_entries { + println!( + " {:<48} {:<7} {:>9} {:>7} {:>8}", + truncate(&entry.entry, 48), + entry.kind, + entry.reachable_functions, + entry.unique_call_steps, + entry.max_call_depth, + ); + } + } + + println!(); + println!(" {}", "reachable functions:".bold()); + if report.reachable_functions.is_empty() { + println!(" {}", "".dimmed()); + } else { + for function in &report.reachable_functions { + println!(" {} {}", "•".green(), function); + } + } + + println!(); + println!(" {}", "not statically reachable:".bold().yellow()); + if report.unreachable_functions.is_empty() { + println!(" {}", "".dimmed()); + } else { + for function in &report.unreachable_functions { + println!(" {} {}", "•".yellow(), function); + } + } + + println!(); + println!(" {}", "reachable local edges:".bold()); + if report.reachable_edges.is_empty() { + println!(" {}", "".dimmed()); + } else { + for edge in &report.reachable_edges { + println!(" {} {} -> {}", "•".green(), edge.caller, edge.callee); + } + } +} + +fn relative_path_string(project_path: &Path, path: &Path) -> String { + path.strip_prefix(project_path) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + fn truncate(value: &str, max: usize) -> String { if value.chars().count() <= max { value.to_string() @@ -691,10 +983,10 @@ fn render_cold_functions(graph: &CallGraph, visit_counts: &HashMap Option { +/// Compute compact static entrypoint reachability for dashboard use. +pub fn entrypoint_reachability_summary( + rs_files: &[PathBuf], + project_path: &Path, +) -> Option { let graph = build_call_graph(rs_files, project_path); if graph.functions.is_empty() { return None; @@ -713,10 +1008,10 @@ pub fn datapath_summary(rs_files: &[PathBuf], project_path: &Path) -> Option, + pub file_function_count: usize, + pub reached_from_crate_count: usize, + pub candidate_count: usize, + pub functions: Vec, +} + pub fn analyze_graph(graph: &CallGraph) -> DeadCodeReport { let reachable = flow::reachable_from_entries(graph); let mut candidates: Vec = graph @@ -49,6 +70,82 @@ pub fn analyze_files(rs_files: &[PathBuf], project_path: &Path) -> DeadCodeRepor analyze_graph(&graph) } +pub fn analyze_file_dead_code( + graph: &CallGraph, + file_functions: &file_metrics::FileFunctionIndex, + file_display_path: &str, +) -> FileDeadCodeReport { + let reachable = flow::reachable_from_entries(graph); + let selected: HashSet = file_functions + .functions + .iter() + .map(|function| function.graph_name.clone()) + .collect(); + + let mut functions = Vec::new(); + for function in file_functions.functions.iter().filter(|function| { + !function.is_test + && !function.is_bench + && !function.is_test_support + && !function.is_binary_wrapper_main + }) { + let mut in_file_fan_in = 0usize; + let mut crate_fan_in = 0usize; + for (caller, callees) in &graph.adjacency { + for callee in callees { + if callee != &function.graph_name { + continue; + } + if selected.contains(caller) { + in_file_fan_in += 1; + } else { + crate_fan_in += 1; + } + } + } + + let reached_from_crate_roots = reachable.contains(&function.graph_name); + let candidate = !reached_from_crate_roots; + let reason = if !candidate { + "reached from crate roots" + } else if crate_fan_in == 0 && in_file_fan_in == 0 { + "not reached from crate roots; no direct, trait, drop, function-item, or closure edge was found" + } else if crate_fan_in == 0 && in_file_fan_in > 0 { + "only reached by cold in-file path; no test/benchmark root reaches that caller" + } else { + "not reached from crate roots; incoming edge source is cold" + }; + + functions.push(FileDeadCodeFunction { + function: function.display_name.clone(), + reached_from_crate_roots, + crate_fan_in, + in_file_fan_in, + candidate, + reason: reason.to_string(), + }); + } + + functions.sort_by(|a, b| a.function.cmp(&b.function)); + let reached_from_crate_count = functions + .iter() + .filter(|function| function.reached_from_crate_roots) + .count(); + let candidate_count = functions + .iter() + .filter(|function| function.candidate) + .count(); + + FileDeadCodeReport { + file: file_display_path.to_string(), + crate_roots: graph.entry_points.clone(), + file_function_count: functions.len(), + reached_from_crate_count, + candidate_count, + functions, + } +} + pub fn render_dead_code_report(report: &DeadCodeReport, verbose: bool) { render::section_header("Dead code candidates"); @@ -102,3 +199,98 @@ pub fn render_dead_code_json(rs_files: &[PathBuf], project_path: &Path) { let report = analyze_files(rs_files, project_path); println!("{}", serde_json::to_string(&report).unwrap()); } + +pub fn render_dead_code_file( + rs_files: &[PathBuf], + project_path: &Path, + file: &Path, + verbose: bool, +) { + let report = analyze_file_dead_code_from_files(rs_files, project_path, file); + render_file_dead_code_report(&report, verbose); +} + +pub fn render_dead_code_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) { + let report = analyze_file_dead_code_from_files(rs_files, project_path, file); + println!("{}", serde_json::to_string(&report).unwrap()); +} + +fn analyze_file_dead_code_from_files( + rs_files: &[PathBuf], + project_path: &Path, + file: &Path, +) -> FileDeadCodeReport { + let symbols = ast_parser::parse_project(rs_files); + let graph = flow::build_call_graph(rs_files, project_path); + let file_functions = + file_metrics::selected_file_functions(&symbols, &graph, project_path, file); + let file_display_path = relative_path_string(project_path, file); + analyze_file_dead_code(&graph, &file_functions, &file_display_path) +} + +pub fn render_file_dead_code_report(report: &FileDeadCodeReport, verbose: bool) { + render::section_header("Dead code candidates"); + + if verbose { + render::verbose_block(&[ + "Candidates are selected-file functions not reachable from crate roots.", + "Fan-in is split between callers in the selected file and callers elsewhere in the crate.", + "This is conservative static evidence, not compiler-confirmed deletion safety.", + ]); + } + + println!( + " {} {} / {} {} {} candidates", + "reached from crate roots".cyan(), + report.reached_from_crate_count.to_string().bold(), + report.file_function_count, + "cold".cyan(), + report.candidate_count.to_string().bold(), + ); + println!( + " {} {}", + "crate roots:".cyan(), + report.crate_roots.join(", ") + ); + + println!(); + println!( + " {:<48} {:>6} {:>11} {:>13} {}", + "function".bold(), + "cold".bold(), + "crate fan-in".bold(), + "in-file fan-in".bold(), + "reason".bold(), + ); + if report.functions.is_empty() { + println!(" {}", "".dimmed()); + } else { + for function in &report.functions { + println!( + " {:<48} {:>6} {:>11} {:>13} {}", + truncate(&function.function, 48), + if function.candidate { "yes" } else { "no" }, + function.crate_fan_in, + function.in_file_fan_in, + function.reason, + ); + } + } +} + +fn truncate(value: &str, max: usize) -> String { + if value.chars().count() <= max { + value.to_string() + } else { + let mut out: String = value.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out + } +} + +fn relative_path_string(project_path: &Path, path: &Path) -> String { + path.strip_prefix(project_path) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} diff --git a/src/deps/mod.rs b/src/deps/mod.rs index c1e50bc..d5f888f 100644 --- a/src/deps/mod.rs +++ b/src/deps/mod.rs @@ -2,6 +2,7 @@ mod render; pub use render::{render_deps, render_deps_json}; +use colored::Colorize; use serde::Serialize; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -36,6 +37,29 @@ pub struct DepAnalysis { pub coupling_pairs: Vec<(String, String, usize)>, } +#[derive(Debug, Clone, Serialize)] +pub struct FileDependency { + pub raw: String, + pub resolved_module: Option, + pub kind: FileDependencyKind, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FileDependencyKind { + Internal, + External, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileDependencyReport { + pub file: String, + pub module: String, + pub imports: usize, + pub internal: Vec, + pub external: Vec, +} + /// Extract use targets from a Rust source file. fn extract_use_targets(source: &str) -> Vec { let ast = match syn::parse_file(source) { @@ -213,6 +237,119 @@ pub fn analyze_deps(rs_files: &[PathBuf], project_path: &Path) -> DepAnalysis { } } +pub fn analyze_file_deps( + rs_files: &[PathBuf], + project_path: &Path, + file: &Path, +) -> FileDependencyReport { + let mut module_names: Vec = rs_files + .iter() + .map(|path| crate_render::module_name_from_path(path, project_path)) + .collect(); + module_names.sort(); + module_names.dedup(); + let known_modules: HashSet = module_names.iter().cloned().collect(); + + let module = crate_render::module_name_from_path(file, project_path); + let file_display = relative_path_string(project_path, file); + let source = std::fs::read_to_string(file).unwrap_or_default(); + let mut use_targets = extract_use_targets(&source); + use_targets.sort(); + use_targets.dedup(); + + let mut internal = Vec::new(); + let mut external = Vec::new(); + for raw in use_targets { + if let Some(resolved_module) = resolve_to_module(&raw, &known_modules) { + internal.push(FileDependency { + raw, + resolved_module: Some(resolved_module), + kind: FileDependencyKind::Internal, + }); + } else { + external.push(FileDependency { + raw, + resolved_module: None, + kind: FileDependencyKind::External, + }); + } + } + + FileDependencyReport { + file: file_display, + module, + imports: internal.len() + external.len(), + internal, + external, + } +} + +pub fn render_deps_file(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) { + let report = analyze_file_deps(rs_files, project_path, file); + render_file_dependency_report(&report, verbose); +} + +pub fn render_deps_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) { + let report = analyze_file_deps(rs_files, project_path, file); + println!("{}", serde_json::to_string(&report).unwrap()); +} + +pub fn render_file_dependency_report(report: &FileDependencyReport, verbose: bool) { + crate_render::section_header("Dependencies"); + + if verbose { + crate_render::verbose_block(&[ + "Imports are raw use/mod targets parsed from the selected file.", + "Internal imports resolve to known modules in the discovered crate files.", + "External imports do not resolve to a known local module.", + ]); + } + + println!( + " {} {} {} {} {} {} {} {}", + "module".cyan(), + report.module.bold(), + "imports".cyan(), + report.imports.to_string().bold(), + "internal".cyan(), + report.internal.len().to_string().bold(), + "external".cyan(), + report.external.len().to_string().bold(), + ); + + println!(); + println!(" {}", "internal imports:".bold()); + if report.internal.is_empty() { + println!(" {}", "".dimmed()); + } else { + for dependency in &report.internal { + println!( + " {} {} -> {}", + "•".green(), + dependency.raw, + dependency.resolved_module.as_deref().unwrap_or("-") + ); + } + } + + println!(); + println!(" {}", "external imports:".bold()); + if report.external.is_empty() { + println!(" {}", "".dimmed()); + } else { + for dependency in &report.external { + println!(" {} {}", "•".yellow(), dependency.raw); + } + } +} + +fn relative_path_string(project_path: &Path, path: &Path) -> String { + path.strip_prefix(project_path) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + /// Compute call cohesion: fraction of functions calling another function in the same module. fn compute_call_cohesion( module_functions: &HashMap>, diff --git a/src/discovery.rs b/src/discovery.rs index f55faa8..bec9085 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -1,6 +1,96 @@ use std::path::{Path, PathBuf}; use walkdir::WalkDir; +#[derive(Debug, Clone)] +pub enum TargetKind { + Project, + File { file_path: PathBuf }, +} + +#[derive(Debug, Clone)] +pub struct AnalysisTarget { + pub project_path: PathBuf, + pub kind: TargetKind, + pub project_rs_files: Vec, + pub target_rs_files: Vec, +} + +pub fn selected_file(target: &AnalysisTarget) -> Option<&Path> { + match &target.kind { + TargetKind::Project => None, + TargetKind::File { file_path } => Some(file_path.as_path()), + } +} + +pub fn files_for_project(target: &AnalysisTarget) -> &[PathBuf] { + &target.project_rs_files +} + +pub fn files_for_target(target: &AnalysisTarget) -> &[PathBuf] { + &target.target_rs_files +} + +pub fn resolve_target(path: &Path) -> Result { + let canonical_path = path + .canonicalize() + .map_err(|e| format!("Cannot resolve path '{}': {}", path.display(), e))?; + + if canonical_path.is_dir() { + let rs_files = discover_rs_files(&canonical_path)?; + return Ok(AnalysisTarget { + project_path: canonical_path, + kind: TargetKind::Project, + project_rs_files: rs_files.clone(), + target_rs_files: rs_files, + }); + } + + if canonical_path.is_file() { + if canonical_path.extension().is_none_or(|ext| ext != "rs") { + return Err(format!( + "--path file must be a Rust source file: {}", + path.display() + )); + } + + let mut ancestor = canonical_path.parent(); + let project_path = loop { + match ancestor { + Some(dir) if dir.join("Cargo.toml").exists() => break dir.to_path_buf(), + Some(dir) => ancestor = dir.parent(), + None => { + return Err(format!( + "No Cargo.toml found above '{}'", + canonical_path.display() + )); + } + } + }; + + let project_rs_files = discover_rs_files(&project_path)?; + if !project_rs_files.contains(&canonical_path) { + return Err(format!( + "file is not under this crate's src, tests, or benches: {}", + canonical_path.display() + )); + } + + return Ok(AnalysisTarget { + project_path, + kind: TargetKind::File { + file_path: canonical_path.clone(), + }, + project_rs_files, + target_rs_files: vec![canonical_path], + }); + } + + Err(format!( + "Cannot resolve path '{}': not a file or directory", + path.display() + )) +} + /// Discover all `.rs` source files in a Rust project. /// /// Given a project root directory, this function: diff --git a/src/file_metrics.rs b/src/file_metrics.rs new file mode 100644 index 0000000..dba0c91 --- /dev/null +++ b/src/file_metrics.rs @@ -0,0 +1,179 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::ast_parser; +use crate::flow::{self, EntryPointKind}; +use crate::render; + +pub struct FileFunction { + pub graph_name: String, + pub display_name: String, + pub line_start: usize, + pub line_end: usize, + pub is_test: bool, + pub is_bench: bool, + pub is_test_support: bool, + pub is_binary_wrapper_main: bool, +} + +pub struct SourceSpanFilter { + pub file: PathBuf, + pub production_spans: Vec<(usize, usize)>, + pub excluded_spans: Vec<(usize, usize)>, +} + +pub struct ProductionFilter { + pub files: Vec, +} + +impl ProductionFilter { + pub fn includes_line(&self, file: &Path, line: usize) -> bool { + self.files + .iter() + .find(|entry| same_path(&entry.file, file)) + .is_some_and(|entry| { + entry + .production_spans + .iter() + .any(|(start, end)| line >= *start && line <= *end) + && !entry + .excluded_spans + .iter() + .any(|(start, end)| line >= *start && line <= *end) + }) + } +} + +pub struct FileFunctionIndex { + pub functions: Vec, + #[allow(dead_code)] + pub graph_name_to_index: HashMap, +} + +pub fn selected_file_functions( + symbols: &ast_parser::ProjectSymbols, + graph: &flow::CallGraph, + project_path: &Path, + file: &Path, +) -> FileFunctionIndex { + let file_module = render::module_name_from_path(file, project_path); + let prefix = if file_module == "main" || file_module == "lib" { + None + } else { + Some(file_module) + }; + + let mut functions: Vec = symbols + .files + .iter() + .filter(|symbols_file| symbols_file.path == file) + .flat_map(|symbols_file| symbols_file.functions.iter()) + .map(|function| { + let graph_name = match &prefix { + Some(module) => format!("{}::{}", module, function.name), + None => function.name.clone(), + }; + let entry_kind = graph.entry_point_kinds.get(&graph_name); + let node = graph.function_nodes.get(&graph_name); + let is_test = matches!(entry_kind, Some(EntryPointKind::Test)) + || node.is_some_and(|node| node.is_test_entry); + let is_bench = matches!(entry_kind, Some(EntryPointKind::Bench)) + || node.is_some_and(|node| node.is_bench_entry); + let is_test_support = node + .map(|node| node.is_test_support) + .unwrap_or(function.is_test_support); + let is_binary_wrapper_main = is_selected_bin_wrapper_main(file, &graph_name); + + FileFunction { + display_name: graph_name.clone(), + graph_name, + line_start: function.line_start, + line_end: function.line_end, + is_test, + is_bench, + is_test_support, + is_binary_wrapper_main, + } + }) + .collect(); + + functions.sort_by(|a, b| { + a.line_start + .cmp(&b.line_start) + .then(a.line_end.cmp(&b.line_end)) + .then(a.graph_name.cmp(&b.graph_name)) + }); + + let graph_name_to_index = functions + .iter() + .enumerate() + .map(|(index, function)| (function.graph_name.clone(), index)) + .collect(); + + FileFunctionIndex { + functions, + graph_name_to_index, + } +} + +pub fn production_filter_for_files( + symbols: &ast_parser::ProjectSymbols, + graph: &flow::CallGraph, + project_path: &Path, + files: &[PathBuf], + selected_file: Option<&Path>, +) -> ProductionFilter { + let mut filters = Vec::new(); + for file in files { + if selected_file.is_some_and(|selected| !same_path(selected, file)) { + continue; + } + let index = selected_file_functions(symbols, graph, project_path, file); + let mut production_spans = Vec::new(); + let mut excluded_spans = Vec::new(); + for function in &index.functions { + let span = (function.line_start, function.line_end); + if function.is_test + || function.is_bench + || function.is_test_support + || function.is_binary_wrapper_main + { + excluded_spans.push(span); + } else { + production_spans.push(span); + } + } + filters.push(SourceSpanFilter { + file: file.clone(), + production_spans, + excluded_spans, + }); + } + ProductionFilter { files: filters } +} + +fn is_selected_bin_wrapper_main(file: &Path, graph_name: &str) -> bool { + (graph_name == "main" || graph_name.ends_with("::main")) && path_has_src_bin(file) +} + +fn path_has_src_bin(path: &Path) -> bool { + let mut saw_src = false; + for component in path.components() { + let name = component.as_os_str(); + if saw_src && name == "bin" { + return true; + } + saw_src = name == "src"; + } + false +} + +fn same_path(a: &Path, b: &Path) -> bool { + if a == b { + return true; + } + match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} diff --git a/src/file_summary.rs b/src/file_summary.rs new file mode 100644 index 0000000..bb6b1ae --- /dev/null +++ b/src/file_summary.rs @@ -0,0 +1,495 @@ +use colored::Colorize; +use serde::Serialize; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use crate::{ + ast_parser, call_trace, datapaths, dead_code, deps, file_metrics, flow, loc, render, symbols, +}; + +#[derive(Debug, Clone, Serialize)] +pub struct FileSummaryReport { + pub cstat_version: String, + pub file: String, + pub project: String, + pub line_counts: loc::FileProjectedLineReachabilityReport, + pub static_reachability: datapaths::FileStaticReachabilityReport, + pub symbols: symbols::FileSymbolReport, + pub dependencies: deps::FileDependencyReport, + pub dead_code: dead_code::FileDeadCodeReport, + pub call_trace: call_trace::FileCallTraceReport, +} + +pub fn render_file_summary(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) { + let report = build_file_summary(rs_files, project_path, file); + render_file_summary_report(&report, verbose); +} + +pub fn render_file_summary_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) { + let report = build_file_summary(rs_files, project_path, file); + println!("{}", serde_json::to_string(&report).unwrap()); +} + +pub fn build_file_summary( + rs_files: &[PathBuf], + project_path: &Path, + file: &Path, +) -> FileSummaryReport { + let file_display_path = call_trace::relative_path_string(project_path, file); + let project = project_name(project_path); + + let symbols_raw = ast_parser::parse_project(rs_files); + let graph = flow::build_call_graph(rs_files, project_path); + let file_functions = + file_metrics::selected_file_functions(&symbols_raw, &graph, project_path, file); + let static_reachability = + datapaths::analyze_file_static_reachability(&graph, &file_functions, &file_display_path); + let reachable_functions: HashSet = static_reachability + .reachable_functions + .iter() + .cloned() + .collect(); + let line_counts = loc::analyze_file_projected_line_reachability( + file, + &file_functions, + &reachable_functions, + &file_display_path, + ) + .unwrap_or_else(|| empty_line_report(file, &file_display_path)); + let symbols = symbols::analyze_symbols_file(&symbols_raw, project_path, file); + let dependencies = deps::analyze_file_deps(rs_files, project_path, file); + let dead_code = dead_code::analyze_file_dead_code(&graph, &file_functions, &file_display_path); + let call_trace = + call_trace::analyze_file_call_traces(&graph, &file_functions, &file_display_path); + + FileSummaryReport { + cstat_version: env!("CARGO_PKG_VERSION").to_string(), + file: file_display_path, + project, + line_counts, + static_reachability, + symbols, + dependencies, + dead_code, + call_trace, + } +} + +fn project_name(project_path: &Path) -> String { + let fallback = || { + project_path + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()) + }; + + let Ok(manifest) = std::fs::read_to_string(project_path.join("Cargo.toml")) else { + return fallback(); + }; + + let mut in_package = false; + for line in manifest.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_package = trimmed == "[package]"; + continue; + } + if in_package { + if let Some(value) = trimmed.strip_prefix("name") { + if let Some((_, raw_name)) = value.split_once('=') { + let name = raw_name.trim().trim_matches('"'); + if !name.is_empty() { + return name.to_string(); + } + } + } + } + } + + fallback() +} + +fn empty_line_report( + file: &Path, + file_display_path: &str, +) -> loc::FileProjectedLineReachabilityReport { + let stats = loc::count_file_lines(file); + loc::FileProjectedLineReachabilityReport { + file: file_display_path.to_string(), + total_lines: stats.as_ref().map_or(0, |stats| stats.total_lines), + code_lines: 0, + projected_reachable_lines: 0, + projected_unreachable_lines: 0, + reachable_spans: Vec::new(), + unreachable_spans: Vec::new(), + } +} + +fn render_file_summary_report(report: &FileSummaryReport, verbose: bool) { + println!( + "\n{} {}", + "cstat file summary".bright_cyan().bold(), + format!("({})", report.file).dimmed(), + ); + println!( + "{}", + "Single-file static metrics projected through the containing crate.".dimmed() + ); + + render_line_counts(&report.line_counts, &report.file, verbose); + render_static_reachability(&report.static_reachability, &report.file, verbose); + render_symbols(&report.symbols, &report.file, verbose); + render_dependencies(&report.dependencies, &report.file, verbose); + render_dead_code(&report.dead_code, &report.file, verbose); + render_call_trace(&report.call_trace, &report.file, verbose); +} + +fn render_line_counts( + report: &loc::FileProjectedLineReachabilityReport, + file: &str, + verbose: bool, +) { + render::section_header("Projected line reachability"); + if verbose { + let full_section = format!("Full section: cstat loc --path {file} -v"); + render::verbose_block(&[ + "Production code lines exclude blank/comment-only lines and test/support/wrapper spans.", + "Projected reachable lines are static function spans reached from project tests/benches.", + full_section.as_str(), + ]); + } + + let pct = percentage(report.projected_reachable_lines, report.code_lines); + println!( + " {} {} {} {} {} {} / {} ({:.1}%) {} {}", + "production code lines".cyan(), + report.code_lines.to_string().bold(), + "physical".cyan(), + report.total_lines.to_string().bold(), + "statically reachable".cyan(), + report.projected_reachable_lines.to_string().bold(), + report.code_lines, + pct, + "not statically reachable".cyan(), + report.projected_unreachable_lines.to_string().bold(), + ); + + if !report.reachable_spans.is_empty() { + println!(); + println!(" {}", "reachable spans:".bold()); + for span in report.reachable_spans.iter().take(6) { + println!( + " {} lines {}-{}", + span.function, span.line_start, span.line_end + ); + } + print_more(report.reachable_spans.len(), 6); + } + + if !report.unreachable_spans.is_empty() { + println!(); + println!(" {}", "unreachable spans:".bold().yellow()); + for span in report.unreachable_spans.iter().take(6) { + println!( + " {} lines {}-{}", + span.function, span.line_start, span.line_end + ); + } + print_more(report.unreachable_spans.len(), 6); + } +} + +fn render_static_reachability( + report: &datapaths::FileStaticReachabilityReport, + file: &str, + verbose: bool, +) { + render::section_header("Static test/benchmark reachability"); + + if verbose { + let full_section = format!("Full section: cstat test-reachability --path {file} -v"); + render::verbose_block(&[ + "Roots are tests and benchmarks discovered anywhere in the project.", + "Reachability follows the full crate call graph, then projects production results onto this file.", + full_section.as_str(), + ]); + } + + let pct = percentage( + report.reachable_function_count, + report.production_function_count, + ); + println!( + " {} {} {} {} / {} ({:.1}%) {} {} / {}", + "test/bench roots".cyan(), + report.test_benchmark_entry_count.to_string().bold(), + "functions reachable".cyan(), + report.reachable_function_count.to_string().bold(), + report.production_function_count, + pct, + "local edges reached".cyan(), + report.reachable_local_edge_count.to_string().bold(), + report.local_edge_count, + ); + + if !report.wrapper_entrypoints.is_empty() { + println!( + " {} {}", + "wrapper entrypoints:".cyan(), + report.wrapper_entrypoints.join(", ") + ); + } + + if !report.entries.is_empty() { + println!(); + println!( + " {:<48} {:<7} {:>9} {:>7} {:>8}", + "entry root".bold(), + "kind".bold(), + "functions".bold(), + "steps".bold(), + "depth".bold(), + ); + for entry in report.entries.iter().take(8) { + println!( + " {:<48} {:<7} {:>9} {:>7} {:>8}", + truncate(&entry.entry, 48), + entry.kind, + entry.reachable_functions, + entry.unique_call_steps, + entry.max_call_depth, + ); + } + print_more(report.entries.len(), 8); + } + + println!(); + println!(" {}", "not statically reachable:".bold().yellow()); + if report.unreachable_functions.is_empty() { + println!(" {}", "".dimmed()); + } else { + for function in report.unreachable_functions.iter().take(8) { + println!(" {} {}", "•".yellow(), function); + } + print_more(report.unreachable_functions.len(), 8); + } +} + +fn render_symbols(report: &symbols::FileSymbolReport, file: &str, verbose: bool) { + render::section_header("Symbols"); + if verbose { + let full_section = format!("Full section: cstat symbols --path {file} -v"); + render::verbose_block(&[ + "Counts are Rust AST items found in the selected file.", + "Functions includes free functions and impl methods.", + full_section.as_str(), + ]); + } + + println!( + " {} {} {} {} {} {} {} {} {} {} {} {} {} {}", + "total".cyan(), + report.total.to_string().bold(), + "fn".cyan(), + report.functions.to_string().bold(), + "struct".cyan(), + report.structs.to_string().bold(), + "enum".cyan(), + report.enums.to_string().bold(), + "trait".cyan(), + report.traits.to_string().bold(), + "const".cyan(), + report.consts.to_string().bold(), + "static".cyan(), + report.statics.to_string().bold(), + ); + + if !report.symbols.is_empty() { + println!(); + println!(" {:<10} {}", "kind".bold(), "symbol".bold()); + for row in report.symbols.iter().take(12) { + println!( + " {:<10} {}", + file_symbol_kind_label(&row.kind), + truncate(&row.symbol, 60), + ); + } + print_more(report.symbols.len(), 12); + } +} + +fn render_dependencies(report: &deps::FileDependencyReport, file: &str, verbose: bool) { + render::section_header("Dependencies"); + if verbose { + let full_section = format!("Full section: cstat deps --path {file} -v"); + render::verbose_block(&[ + "Imports are raw use/mod targets parsed from this file.", + "Internal imports resolve to known modules in the containing crate.", + full_section.as_str(), + ]); + } + + println!( + " {} {} {} {} {} {} {} {}", + "module".cyan(), + report.module.bold(), + "imports".cyan(), + report.imports.to_string().bold(), + "internal".cyan(), + report.internal.len().to_string().bold(), + "external".cyan(), + report.external.len().to_string().bold(), + ); + + print_dependency_list("internal", &report.internal, 6, true); + print_dependency_list("external", &report.external, 6, false); +} + +fn render_dead_code(report: &dead_code::FileDeadCodeReport, file: &str, verbose: bool) { + render::section_header("Dead code candidates"); + if verbose { + let full_section = format!("Full section: cstat dead-code --path {file} -v"); + render::verbose_block(&[ + "Candidates are file functions not reachable from crate roots.", + "Fan-in distinguishes crate callers from callers inside this file.", + full_section.as_str(), + ]); + } + + println!( + " {} {} / {} {} {} candidates", + "reached from crate roots".cyan(), + report.reached_from_crate_count.to_string().bold(), + report.file_function_count, + "cold".cyan(), + report.candidate_count.to_string().bold(), + ); + + let candidates: Vec<_> = report + .functions + .iter() + .filter(|function| function.candidate) + .collect(); + println!(); + println!( + " {:<44} {:>11} {:>13} {}", + "function".bold(), + "crate fan-in".bold(), + "in-file fan-in".bold(), + "reason".bold(), + ); + if candidates.is_empty() { + println!(" {}", "".dimmed()); + } else { + for function in candidates.iter().take(8) { + println!( + " {:<44} {:>11} {:>13} {}", + truncate(&function.function, 44), + function.crate_fan_in, + function.in_file_fan_in, + function.reason, + ); + } + print_more(candidates.len(), 8); + } +} + +fn render_call_trace(report: &call_trace::FileCallTraceReport, file: &str, verbose: bool) { + render::section_header("Call trace size"); + if verbose { + let full_section = format!("Full section: cstat call-trace --path {file} -v"); + render::verbose_block(&[ + "Rows rank non-test functions by the size of their static call tree.", + "Direct callees are truncated for the summary; full trees are in the section command.", + full_section.as_str(), + ]); + } + + println!( + " {:<44} {:>7} {:>5} {}", + "function".bold(), + "reached".bold(), + "depth".bold(), + "direct callees".bold(), + ); + let width = render::terminal_width(); + let callee_width = width.saturating_sub(64).max(24); + for function in report.functions.iter().take(12) { + let callees = if function.direct_callees.is_empty() { + "-".to_string() + } else { + function.direct_callees.join(", ") + }; + println!( + " {:<44} {:>7} {:>5} {}", + truncate(&function.function, 44), + function.functions_reached, + function.max_depth, + truncate(&callees, callee_width), + ); + } + print_more(report.functions.len(), 12); +} + +fn print_dependency_list( + label: &str, + dependencies: &[deps::FileDependency], + limit: usize, + show_resolved: bool, +) { + println!(); + println!(" {}", format!("{label} imports:").bold()); + if dependencies.is_empty() { + println!(" {}", "".dimmed()); + return; + } + + for dependency in dependencies.iter().take(limit) { + if show_resolved { + println!( + " {} {} -> {}", + "•".cyan(), + dependency.raw, + dependency.resolved_module.as_deref().unwrap_or("-"), + ); + } else { + println!(" {} {}", "•".cyan(), dependency.raw); + } + } + print_more(dependencies.len(), limit); +} + +fn file_symbol_kind_label(kind: &symbols::FileSymbolKind) -> &'static str { + match kind { + symbols::FileSymbolKind::Fn => "fn", + symbols::FileSymbolKind::Struct => "struct", + symbols::FileSymbolKind::Enum => "enum", + symbols::FileSymbolKind::Trait => "trait", + symbols::FileSymbolKind::Const => "const", + symbols::FileSymbolKind::Static => "static", + } +} + +fn percentage(numerator: usize, denominator: usize) -> f64 { + if denominator == 0 { + 0.0 + } else { + numerator as f64 / denominator as f64 * 100.0 + } +} + +fn print_more(total: usize, shown: usize) { + if total > shown { + println!(" … +{} more", total - shown); + } +} + +fn truncate(value: &str, max: usize) -> String { + if value.chars().count() <= max { + value.to_string() + } else { + let mut out: String = value.chars().take(max.saturating_sub(1)).collect(); + out.push('…'); + out + } +} diff --git a/src/flow.rs b/src/flow.rs index 91dbc8e..6b8cbb6 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -7,13 +7,6 @@ use syn::{File as SynFile, ImplItem, Item}; use crate::render; -/// A call edge: caller function invokes callee function. -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub struct CallEdge { - pub caller: String, - pub callee: String, -} - /// Kind of root used when tracing call paths. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -33,10 +26,33 @@ impl EntryPointKind { } } +type StructFieldMap = HashMap<(String, String), String>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FunctionKind { + Free, + InherentMethod, + TraitMethod, + DropImpl, +} + +#[derive(Debug, Clone)] +pub struct FunctionNode { + pub kind: FunctionKind, + pub self_type: Option, + pub trait_name: Option, + pub method_name: Option, + pub is_test_entry: bool, + pub is_bench_entry: bool, + pub is_test_support: bool, +} + /// Result of call graph analysis. pub struct CallGraph { /// All known function names (qualified: Module::method or bare function). pub functions: Vec, + /// Function metadata keyed by canonical function name. + pub function_nodes: HashMap, /// Edges: caller -> list of callees. pub adjacency: HashMap>, /// Entry points (main, tests, and benchmarks). @@ -47,67 +63,433 @@ pub struct CallGraph { pub cycle_edges: HashSet<(String, String)>, } +#[derive(Debug, Clone)] +enum RawCallSite { + Path { + path: String, + arg_functions: Vec>, + arg_types: Vec>, + }, + Method { + receiver_name: Option, + receiver_hint: Option, + method: String, + }, + LocalCallable { + name: String, + }, + ImplicitDrop { + type_name: String, + }, +} + +#[derive(Debug, Clone, Default)] +struct SignatureInfo { + param_types: HashMap, + param_indices: HashMap, + generic_params: HashSet, + return_type: Option, +} + +#[derive(Debug, Clone, Default)] +struct ItemContext { + module_parts: Vec, + in_cfg_test: bool, + in_tests_module: bool, +} + +impl ItemContext { + fn is_test_support_context(&self) -> bool { + self.in_cfg_test || self.in_tests_module + } + + fn child_module(&self, module: &syn::ItemMod) -> Self { + let mut next = self.clone(); + next.in_cfg_test |= has_cfg_test_attr(&module.attrs); + next.in_tests_module |= module.ident == "tests"; + next.module_parts.push(module.ident.to_string()); + next + } +} + +#[derive(Debug, Clone)] +struct FunctionRecord { + name: String, + node: FunctionNode, + signature: SignatureInfo, + entry_kind: Option, + raw_calls: Vec, + callable_param_invocations: Vec, +} + /// AST visitor that extracts call sites from function bodies. struct CallSiteExtractor { - /// Current function being visited (qualified name). current_fn: String, - /// Collected call edges. - edges: Vec, + module_parts: Vec, + is_test_support: bool, + current_self_type: Option, + struct_fields: StructFieldMap, + raw_calls: Vec, + extra_records: Vec, + signature: SignatureInfo, + callable_params: HashMap, + callable_param_invocations: HashSet, + local_types: HashMap, + closure_bindings: HashMap, + closure_counter: usize, + callable_type_aliases: HashSet, +} + +impl CallSiteExtractor { + fn new( + current_fn: String, + module_parts: Vec, + is_test_support: bool, + current_self_type: Option, + struct_fields: &StructFieldMap, + callable_type_aliases: &HashSet, + sig: Option<&syn::Signature>, + ) -> Self { + let mut signature = SignatureInfo::default(); + let mut callable_params = HashMap::new(); + signature.generic_params = sig.map(generic_type_param_names).unwrap_or_default(); + let callable_generics = sig.map(callable_generic_names).unwrap_or_default(); + signature.return_type = + sig.and_then(|sig| return_type_hint(&sig.output, current_self_type.as_deref())); + + if let Some(sig) = sig { + let mut typed_index = 0usize; + for input in &sig.inputs { + if let syn::FnArg::Typed(pat_type) = input { + if let Some(name) = pat_ident(&pat_type.pat) { + signature.param_indices.insert(name.clone(), typed_index); + if let Some(type_name) = type_hint_from_type(&pat_type.ty) { + signature.param_types.insert(name.clone(), type_name); + } + if is_callable_type(&pat_type.ty, &callable_generics, callable_type_aliases) + { + callable_params.insert(name.clone(), typed_index); + } + } + typed_index += 1; + } + } + } + + let mut extractor = Self { + current_fn, + module_parts, + is_test_support, + current_self_type, + struct_fields: struct_fields.clone(), + raw_calls: Vec::new(), + extra_records: Vec::new(), + signature, + callable_params, + callable_param_invocations: HashSet::new(), + local_types: HashMap::new(), + closure_bindings: HashMap::new(), + closure_counter: 0, + callable_type_aliases: callable_type_aliases.clone(), + }; + + if let Some(sig) = sig { + for input in &sig.inputs { + match input { + syn::FnArg::Receiver(receiver) if receiver.reference.is_none() => { + if let Some(type_name) = extractor.current_self_type.clone() { + extractor.push_constructed_type(type_name); + } + } + syn::FnArg::Typed(pat_type) => { + if let Some(type_name) = owned_type_hint_from_type(&pat_type.ty) { + if !is_primitive_type_hint(&type_name) { + extractor.push_constructed_type(type_name); + } + } + } + _ => {} + } + } + } + + extractor + } + + fn signature_info(&self) -> SignatureInfo { + self.signature.clone() + } + + fn finish(self) -> (Vec, Vec, Vec) { + let mut invoked: Vec = self.callable_param_invocations.into_iter().collect(); + invoked.sort_unstable(); + (self.raw_calls, invoked, self.extra_records) + } + + fn push_constructed_type(&mut self, type_name: String) { + if type_name != "?" { + self.raw_calls.push(RawCallSite::ImplicitDrop { type_name }); + } + } + + fn record_constructed_type(&mut self, type_name: String) { + if !is_primitive_type_hint(&type_name) { + self.push_constructed_type(type_name); + } + } + + fn record_constructed_expr(&mut self, expr: &syn::Expr) { + if let Some(type_name) = expr_constructed_type(expr) { + self.record_constructed_type(type_name); + } + } + + fn visit_macro_tokens(&mut self, tokens: proc_macro2::TokenStream) { + use syn::parse::Parser; + + if let Ok(expr) = syn::parse2::(tokens.clone()) { + self.visit_expr(&expr); + return; + } + + let parser = syn::punctuated::Punctuated::::parse_terminated; + if let Ok(expressions) = parser.parse2(tokens) { + for expr in expressions { + self.visit_expr(&expr); + } + } + } + + fn argument_function_and_type(&mut self, expr: &syn::Expr) -> (Option, Option) { + let function = match expr { + syn::Expr::Closure(closure) => Some(self.create_closure_record(closure)), + _ => function_item_path_expr(expr), + }; + let type_hint = expr_type_hint( + expr, + &self.local_types, + &self.signature.param_types, + self.current_self_type.as_deref(), + &self.struct_fields, + ); + (function, type_hint) + } + + fn push_registered_callback(&mut self, callback: String) { + self.raw_calls.push(RawCallSite::Path { + path: callback, + arg_functions: Vec::new(), + arg_types: Vec::new(), + }); + } + + fn create_closure_record(&mut self, closure: &syn::ExprClosure) -> String { + self.closure_counter += 1; + let closure_name = format!("{}::{{closure#{}}}", self.current_fn, self.closure_counter); + let mut extractor = CallSiteExtractor::new( + closure_name.clone(), + self.module_parts.clone(), + self.is_test_support, + None, + &self.struct_fields, + &self.callable_type_aliases, + None, + ); + + match closure.body.as_ref() { + syn::Expr::Block(block) => extractor.visit_block(&block.block), + expr => extractor.visit_expr(expr), + } + + let signature = extractor.signature_info(); + let (raw_calls, callable_param_invocations, mut nested_extra) = extractor.finish(); + let node = FunctionNode { + kind: FunctionKind::Free, + self_type: None, + trait_name: None, + method_name: None, + is_test_entry: false, + is_bench_entry: false, + is_test_support: self.is_test_support, + }; + self.extra_records.push(FunctionRecord { + name: closure_name.clone(), + node, + signature, + entry_kind: None, + raw_calls, + callable_param_invocations, + }); + self.extra_records.append(&mut nested_extra); + closure_name + } } impl<'ast> Visit<'ast> for CallSiteExtractor { - fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { - // Extract the callee name from the call expression - if let Some(name) = expr_to_callee_name(&node.func) { - self.edges.push(CallEdge { - caller: self.current_fn.clone(), - callee: name, - }); + fn visit_local(&mut self, local: &'ast syn::Local) { + let local_name = pat_ident(&local.pat); + let explicit_type = pat_type_hint(&local.pat); + + if let Some(init) = &local.init { + if let syn::Expr::Closure(closure) = init.expr.as_ref() { + if let Some(name) = local_name { + let closure_name = self.create_closure_record(closure); + self.closure_bindings.insert(name, closure_name); + } + return; + } + + let init_type = expr_constructed_type(init.expr.as_ref()); + if let Some(name) = local_name { + if let Some(type_name) = explicit_type.or_else(|| init_type.clone()) { + self.local_types.insert(name, type_name.clone()); + self.record_constructed_type(type_name); + } + } else { + self.record_constructed_expr(init.expr.as_ref()); + } + + self.visit_expr(init.expr.as_ref()); } + } + + fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) { + let mut arg_functions = Vec::new(); + let mut arg_types = Vec::new(); + for arg in &node.args { + let (arg_function, arg_type) = self.argument_function_and_type(arg); + arg_functions.push(arg_function); + arg_types.push(arg_type); + } + + if let syn::Expr::Path(path) = node.func.as_ref() { + if let Some(type_name) = constructed_drop_type_from_call_path(&path.path) { + self.record_constructed_type(type_name); + } + } + if let Some(path) = expr_to_callee_name(&node.func) { + if let Some(closure_name) = self.closure_bindings.get(&path).cloned() { + self.raw_calls.push(RawCallSite::Path { + path: closure_name, + arg_functions: Vec::new(), + arg_types: Vec::new(), + }); + } else if let Some(index) = self.callable_params.get(&path).copied() { + self.callable_param_invocations.insert(index); + self.raw_calls + .push(RawCallSite::LocalCallable { name: path }); + } else { + self.raw_calls.push(RawCallSite::Path { + path, + arg_functions, + arg_types, + }); + } + + for arg in &node.args { + self.visit_expr(arg); + } + return; + } + syn::visit::visit_expr_call(self, node); } fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { - // Method calls: obj.method(args) — we record just the method name - let name = node.method.to_string(); - self.edges.push(CallEdge { - caller: self.current_fn.clone(), - callee: name, + let receiver_name = receiver_root_name(&node.receiver); + let receiver_hint = receiver_type_hint( + &node.receiver, + &self.local_types, + &self.signature.param_types, + self.current_self_type.as_deref(), + &self.struct_fields, + ); + self.raw_calls.push(RawCallSite::Method { + receiver_name, + receiver_hint, + method: node.method.to_string(), }); - syn::visit::visit_expr_method_call(self, node); + self.visit_expr(&node.receiver); + for arg in &node.args { + let (arg_function, _) = self.argument_function_and_type(arg); + if let Some(callback) = arg_function { + self.push_registered_callback(callback); + } + self.visit_expr(arg); + } + } + + fn visit_expr_struct(&mut self, node: &'ast syn::ExprStruct) { + self.record_constructed_expr(&syn::Expr::Struct(node.clone())); + syn::visit::visit_expr_struct(self, node); + } + + fn visit_expr_path(&mut self, node: &'ast syn::ExprPath) { + self.record_constructed_expr(&syn::Expr::Path(node.clone())); + syn::visit::visit_expr_path(self, node); + } + + fn visit_expr_macro(&mut self, node: &'ast syn::ExprMacro) { + self.visit_macro_tokens(node.mac.tokens.clone()); + } + + fn visit_stmt_macro(&mut self, node: &'ast syn::StmtMacro) { + self.visit_macro_tokens(node.mac.tokens.clone()); + } + + fn visit_expr_closure(&mut self, _node: &'ast syn::ExprClosure) { + // Closure bodies are recorded under synthetic closure nodes when the closure + // is bound or passed as a callable value. Do not attribute them to the + // enclosing function. } } /// Try to extract a readable callee name from a call expression. fn expr_to_callee_name(expr: &syn::Expr) -> Option { match expr { - syn::Expr::Path(ep) => { - let segments: Vec = ep - .path - .segments - .iter() - .map(|s| s.ident.to_string()) - .collect(); - Some(segments.join("::")) - } + syn::Expr::Path(ep) => Some(expr_path_to_string(ep)), _ => None, } } +fn expr_path_to_string(path: &syn::ExprPath) -> String { + let segments: Vec = path + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect(); + if let Some(qself) = &path.qself { + if let Some(method) = segments.last() { + let trait_name = if segments.len() > 1 { + segments[..segments.len() - 1].join("::") + } else { + String::new() + }; + let self_type = type_to_short_string(&qself.ty); + if trait_name.is_empty() { + format!("{self_type}::{method}") + } else { + format!("<{self_type} as {trait_name}>::{method}") + } + } else { + type_to_short_string(&qself.ty) + } + } else { + segments.join("::") + } +} + /// Extract all function definitions and their call sites from a single file. -fn extract_calls_from_file( - path: &Path, - project_path: &Path, -) -> (Vec<(String, Option)>, Vec) { - // Returns: (list of (fn_name, entry kind), list of call edges) +fn extract_calls_from_file(path: &Path, project_path: &Path) -> Vec { let content = match std::fs::read_to_string(path) { Ok(c) => c, - Err(_) => return (vec![], vec![]), + Err(_) => return Vec::new(), }; let ast: SynFile = match syn::parse_file(&content) { Ok(f) => f, - Err(_) => return (vec![], vec![]), + Err(_) => return Vec::new(), }; let module = render::module_name_from_path(path, project_path); @@ -116,74 +498,199 @@ fn extract_calls_from_file( } else { module.split("::").map(|s| s.to_string()).collect() }; - let mut functions: Vec<(String, Option)> = Vec::new(); - let mut all_edges: Vec = Vec::new(); - + let context = ItemContext { + module_parts: base_modules, + in_cfg_test: false, + in_tests_module: false, + }; + let mut struct_fields = StructFieldMap::new(); + collect_struct_fields(&ast.items, &context, &mut struct_fields); + let mut callable_type_aliases = HashSet::new(); + collect_callable_type_aliases(&ast.items, &context, &mut callable_type_aliases); + let mut records = Vec::new(); extract_items( &ast.items, path, project_path, - &base_modules, - &mut functions, - &mut all_edges, + &context, + &struct_fields, + &callable_type_aliases, + &mut records, ); + records +} - (functions, all_edges) +fn collect_struct_fields(items: &[Item], context: &ItemContext, fields: &mut StructFieldMap) { + for item in items { + match item { + Item::Struct(strukt) => { + let type_name = qualify_name(&context.module_parts, &strukt.ident.to_string()); + if let syn::Fields::Named(named) = &strukt.fields { + for field in &named.named { + let Some(field_name) = field.ident.as_ref().map(|ident| ident.to_string()) + else { + continue; + }; + if let Some(field_type) = type_hint_from_type(&field.ty) { + fields.insert((type_name.clone(), field_name), field_type); + } + } + } + } + Item::Mod(module) => { + if let Some((_, nested_items)) = &module.content { + let next = context.child_module(module); + collect_struct_fields(nested_items, &next, fields); + } + } + _ => {} + } + } +} + +fn collect_callable_type_aliases( + items: &[Item], + context: &ItemContext, + aliases: &mut HashSet, +) { + for item in items { + match item { + Item::Type(alias) if type_alias_target_is_callable(&alias.ty) => { + let short = alias.ident.to_string(); + aliases.insert(short.clone()); + aliases.insert(qualify_name(&context.module_parts, &short)); + } + Item::Mod(module) => { + if let Some((_, nested_items)) = &module.content { + let next = context.child_module(module); + collect_callable_type_aliases(nested_items, &next, aliases); + } + } + _ => {} + } + } +} + +fn type_alias_target_is_callable(ty: &syn::Type) -> bool { + matches!(ty, syn::Type::BareFn(_)) } fn extract_items( items: &[Item], path: &Path, project_path: &Path, - module_parts: &[String], - functions: &mut Vec<(String, Option)>, - all_edges: &mut Vec, + context: &ItemContext, + struct_fields: &StructFieldMap, + callable_type_aliases: &HashSet, + records: &mut Vec, ) { for item in items { match item { Item::Fn(func) => { let name = func.sig.ident.to_string(); - let qualified = qualify_name(module_parts, &name); + let qualified = qualify_name(&context.module_parts, &name); let entry_kind = classify_entry_point(&name, &func.attrs, path, project_path); - functions.push((qualified.clone(), entry_kind)); - - let mut extractor = CallSiteExtractor { - current_fn: qualified, - edges: Vec::new(), - }; + let is_test_support = context.is_test_support_context() + && !matches!( + entry_kind, + Some(EntryPointKind::Test) | Some(EntryPointKind::Bench) + ); + let mut extractor = CallSiteExtractor::new( + qualified.clone(), + context.module_parts.clone(), + is_test_support, + None, + struct_fields, + callable_type_aliases, + Some(&func.sig), + ); extractor.visit_block(&func.block); - all_edges.extend(extractor.edges); + let signature = extractor.signature_info(); + let (raw_calls, callable_param_invocations, mut extra_records) = extractor.finish(); + let node = FunctionNode { + kind: FunctionKind::Free, + self_type: None, + trait_name: None, + method_name: None, + is_test_entry: matches!(entry_kind, Some(EntryPointKind::Test)), + is_bench_entry: matches!(entry_kind, Some(EntryPointKind::Bench)), + is_test_support, + }; + records.push(FunctionRecord { + name: qualified, + node, + signature, + entry_kind, + raw_calls, + callable_param_invocations, + }); + records.append(&mut extra_records); } Item::Impl(imp) => { - let type_name = qualify_name(module_parts, &type_to_short_string(&imp.self_ty)); + let type_name = + qualify_name(&context.module_parts, &type_to_short_string(&imp.self_ty)); + let trait_name = imp.trait_.as_ref().map(|(_, path, _)| path_to_string(path)); + let kind = match trait_name.as_deref().map(last_path_segment) { + Some("Drop") => FunctionKind::DropImpl, + Some(_) => FunctionKind::TraitMethod, + None => FunctionKind::InherentMethod, + }; for impl_item in &imp.items { if let ImplItem::Fn(method) = impl_item { let method_name = method.sig.ident.to_string(); let qualified = format!("{}::{}", type_name, method_name); let entry_kind = classify_entry_point(&method_name, &method.attrs, path, project_path); - functions.push((qualified.clone(), entry_kind)); - - let mut extractor = CallSiteExtractor { - current_fn: qualified, - edges: Vec::new(), - }; + let is_test_support = context.is_test_support_context() + && !matches!( + entry_kind, + Some(EntryPointKind::Test) | Some(EntryPointKind::Bench) + ); + let mut extractor = CallSiteExtractor::new( + qualified.clone(), + context.module_parts.clone(), + is_test_support, + Some(type_name.clone()), + struct_fields, + callable_type_aliases, + Some(&method.sig), + ); extractor.visit_block(&method.block); - all_edges.extend(extractor.edges); + let signature = extractor.signature_info(); + let (raw_calls, callable_param_invocations, mut extra_records) = + extractor.finish(); + let node = FunctionNode { + kind, + self_type: Some(type_name.clone()), + trait_name: trait_name.clone(), + method_name: Some(method_name), + is_test_entry: matches!(entry_kind, Some(EntryPointKind::Test)), + is_bench_entry: matches!(entry_kind, Some(EntryPointKind::Bench)), + is_test_support, + }; + records.push(FunctionRecord { + name: qualified, + node, + signature, + entry_kind, + raw_calls, + callable_param_invocations, + }); + records.append(&mut extra_records); } } } Item::Mod(module) => { if let Some((_, nested_items)) = &module.content { - let mut nested_parts = module_parts.to_vec(); - nested_parts.push(module.ident.to_string()); + let next = context.child_module(module); extract_items( nested_items, path, project_path, - &nested_parts, - functions, - all_edges, + &next, + struct_fields, + callable_type_aliases, + records, ); } } @@ -226,55 +733,720 @@ fn has_attr_named(attrs: &[syn::Attribute], wanted: &str) -> bool { }) } +fn has_cfg_test_attr(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|attr| { + attr.path() + .segments + .last() + .is_some_and(|segment| segment.ident == "cfg") + && match &attr.meta { + syn::Meta::List(list) => list.tokens.to_string().contains("test"), + _ => false, + } + }) +} + fn is_under_dir(path: &Path, project_path: &Path, dir_name: &str) -> bool { let rel = path.strip_prefix(project_path).unwrap_or(path); rel.components() .any(|component| component.as_os_str() == dir_name) } +fn path_to_string(path: &syn::Path) -> String { + path.segments + .iter() + .map(|s| s.ident.to_string()) + .collect::>() + .join("::") +} + +fn last_path_segment(path: &str) -> &str { + path.rsplit("::").next().unwrap_or(path) +} + +fn short_type_name(path: &str) -> &str { + path.rsplit("::").next().unwrap_or(path) +} + /// Convert a Type to a short string. fn type_to_short_string(ty: &syn::Type) -> String { + type_hint_from_type(ty).unwrap_or_else(|| "?".to_string()) +} + +fn type_hint_from_type(ty: &syn::Type) -> Option { match ty { - syn::Type::Path(tp) => tp - .path - .segments - .iter() - .map(|s| s.ident.to_string()) - .collect::>() - .join("::"), - _ => "?".to_string(), + syn::Type::Reference(reference) => type_hint_from_type(&reference.elem), + syn::Type::Path(tp) => { + let last = tp.path.segments.last()?; + if last.ident == "Box" { + if let syn::PathArguments::AngleBracketed(args) = &last.arguments { + for arg in &args.args { + if let syn::GenericArgument::Type(inner) = arg { + return type_hint_from_type(inner); + } + } + } + } + Some(path_to_string(&tp.path)) + } + _ => None, } } -/// Resolve a callee name against known function names. -/// Tries exact match first, then caller-local sibling match, then suffix match. -fn resolve_callee(callee: &str, caller: &str, known: &HashMap) -> Option { - if known.contains_key(callee) { - return Some(callee.to_string()); +fn return_type_hint(output: &syn::ReturnType, current_self_type: Option<&str>) -> Option { + match output { + syn::ReturnType::Default => None, + syn::ReturnType::Type(_, ty) => owned_return_type_hint_from_type(ty, current_self_type), } +} - if !callee.contains("::") { - if let Some((caller_parent, _)) = caller.rsplit_once("::") { - let sibling = format!("{caller_parent}::{callee}"); - if known.contains_key(&sibling) { - return Some(sibling); +fn owned_return_type_hint_from_type( + ty: &syn::Type, + current_self_type: Option<&str>, +) -> Option { + match ty { + syn::Type::Reference(_) => None, + syn::Type::Paren(paren) => owned_return_type_hint_from_type(&paren.elem, current_self_type), + syn::Type::Path(tp) => { + let last = tp.path.segments.last()?; + if last.ident == "Self" { + return current_self_type.map(str::to_string); + } + if matches!(last.ident.to_string().as_str(), "Option" | "Result") { + if let syn::PathArguments::AngleBracketed(args) = &last.arguments { + for arg in &args.args { + if let syn::GenericArgument::Type(inner) = arg { + return owned_return_type_hint_from_type(inner, current_self_type); + } + } + } + } + let name = path_to_string(&tp.path); + if name == "Self" { + current_self_type.map(str::to_string) + } else { + Some(name) + } + } + _ => None, + } +} + +fn pat_ident(pat: &syn::Pat) -> Option { + match pat { + syn::Pat::Ident(ident) => Some(ident.ident.to_string()), + syn::Pat::Type(pat_type) => pat_ident(&pat_type.pat), + syn::Pat::Reference(reference) => pat_ident(&reference.pat), + _ => None, + } +} + +fn pat_type_hint(pat: &syn::Pat) -> Option { + match pat { + syn::Pat::Type(pat_type) => type_hint_from_type(&pat_type.ty), + syn::Pat::Reference(reference) => pat_type_hint(&reference.pat), + _ => None, + } +} + +fn function_item_path_expr(expr: &syn::Expr) -> Option { + match expr { + syn::Expr::Path(path) if path.qself.is_none() => Some(path_to_string(&path.path)), + syn::Expr::Reference(reference) => function_item_path_expr(&reference.expr), + syn::Expr::Cast(cast) => function_item_path_expr(&cast.expr), + syn::Expr::Paren(paren) => function_item_path_expr(&paren.expr), + syn::Expr::Group(group) => function_item_path_expr(&group.expr), + _ => None, + } +} + +fn expr_constructed_type(expr: &syn::Expr) -> Option { + match expr { + syn::Expr::Struct(strukt) => Some(path_to_string(&strukt.path)), + syn::Expr::Path(path) if path.qself.is_none() => { + let name = path_to_string(&path.path); + if first_type_segment(&name).is_some() { + Some(name) + } else { + None + } + } + syn::Expr::Call(call) => match call.func.as_ref() { + syn::Expr::Path(path) => constructed_type_from_call_path(&path.path), + _ => None, + }, + syn::Expr::Reference(reference) => expr_constructed_type(&reference.expr), + syn::Expr::Paren(paren) => expr_constructed_type(&paren.expr), + syn::Expr::Group(group) => expr_constructed_type(&group.expr), + _ => None, + } +} + +fn constructed_type_from_call_path(path: &syn::Path) -> Option { + constructed_drop_type_from_call_path(path) +} + +fn constructed_drop_type_from_call_path(path: &syn::Path) -> Option { + let mut segments: Vec = path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect(); + if segments.is_empty() { + return None; + } + if segments.len() == 1 { + let name = segments.pop().unwrap(); + return if first_type_segment(&name).is_some() { + Some(name) + } else { + None + }; + } + if segments.last().is_some_and(|last| last == "new") { + segments.pop(); + let receiver = segments.join("::"); + return if first_type_segment(&receiver).is_some() { + Some(receiver) + } else { + None + }; + } + None +} + +fn first_type_segment(path: &str) -> Option<&str> { + path.split("::") + .find(|segment| segment.chars().next().is_some_and(|c| c.is_uppercase())) +} + +fn receiver_root_name(receiver: &syn::Expr) -> Option { + match receiver { + syn::Expr::Path(path) if path.qself.is_none() && path.path.segments.len() == 1 => { + Some(path.path.segments[0].ident.to_string()) + } + syn::Expr::Reference(reference) => receiver_root_name(&reference.expr), + syn::Expr::Field(field) => receiver_root_name(&field.base), + syn::Expr::Paren(paren) => receiver_root_name(&paren.expr), + syn::Expr::Group(group) => receiver_root_name(&group.expr), + _ => None, + } +} + +fn expr_type_hint( + expr: &syn::Expr, + local_types: &HashMap, + param_types: &HashMap, + current_self_type: Option<&str>, + struct_fields: &StructFieldMap, +) -> Option { + match expr { + syn::Expr::Path(path) if path.qself.is_none() && path.path.segments.len() == 1 => { + let name = path.path.segments[0].ident.to_string(); + if name == "self" { + return current_self_type.map(str::to_string); + } + local_types + .get(&name) + .or_else(|| param_types.get(&name)) + .cloned() + .or_else(|| expr_constructed_type(expr)) + } + syn::Expr::Reference(reference) => expr_type_hint( + &reference.expr, + local_types, + param_types, + current_self_type, + struct_fields, + ), + syn::Expr::Field(field) => { + let base_type = expr_type_hint( + &field.base, + local_types, + param_types, + current_self_type, + struct_fields, + )?; + let field_name = match &field.member { + syn::Member::Named(ident) => ident.to_string(), + syn::Member::Unnamed(_) => return None, + }; + lookup_field_type(&base_type, &field_name, struct_fields) + } + syn::Expr::Struct(_) | syn::Expr::Call(_) => expr_constructed_type(expr), + syn::Expr::Paren(paren) => expr_type_hint( + &paren.expr, + local_types, + param_types, + current_self_type, + struct_fields, + ), + syn::Expr::Group(group) => expr_type_hint( + &group.expr, + local_types, + param_types, + current_self_type, + struct_fields, + ), + _ => None, + } +} + +fn lookup_field_type( + base_type: &str, + field_name: &str, + struct_fields: &StructFieldMap, +) -> Option { + struct_fields + .get(&(base_type.to_string(), field_name.to_string())) + .cloned() + .or_else(|| { + let short_base = short_type_name(base_type); + struct_fields.iter().find_map(|((ty, field), value)| { + (field == field_name && short_type_name(ty) == short_base).then(|| value.clone()) + }) + }) +} + +fn receiver_type_hint( + receiver: &syn::Expr, + local_types: &HashMap, + param_types: &HashMap, + current_self_type: Option<&str>, + struct_fields: &StructFieldMap, +) -> Option { + expr_type_hint( + receiver, + local_types, + param_types, + current_self_type, + struct_fields, + ) +} + +fn generic_type_param_names(sig: &syn::Signature) -> HashSet { + sig.generics + .params + .iter() + .filter_map(|param| match param { + syn::GenericParam::Type(ty) => Some(ty.ident.to_string()), + _ => None, + }) + .collect() +} + +fn owned_type_hint_from_type(ty: &syn::Type) -> Option { + match ty { + syn::Type::Reference(_) => None, + _ => type_hint_from_type(ty), + } +} + +fn is_primitive_type_hint(type_name: &str) -> bool { + matches!( + type_name, + "bool" + | "char" + | "str" + | "String" + | "usize" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "isize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "f32" + | "f64" + ) +} + +fn callable_generic_names(sig: &syn::Signature) -> HashSet { + let mut names = HashSet::new(); + for param in &sig.generics.params { + if let syn::GenericParam::Type(ty) = param { + if ty.bounds.iter().any(|bound| trait_bound_is_fn(bound)) { + names.insert(ty.ident.to_string()); } } } - - let suffix = format!("::{}", callee); - let mut matches: Vec<&String> = known.keys().filter(|k| k.ends_with(&suffix)).collect(); - if matches.len() == 1 { - return Some(matches[0].clone()); - } - - if callee.contains("::") { - matches = known.keys().filter(|k| k.ends_with(callee)).collect(); - if matches.len() == 1 { - return Some(matches[0].clone()); + if let Some(where_clause) = &sig.generics.where_clause { + for predicate in &where_clause.predicates { + if let syn::WherePredicate::Type(predicate) = predicate { + if predicate + .bounds + .iter() + .any(|bound| trait_bound_is_fn(bound)) + { + if let syn::Type::Path(path) = &predicate.bounded_ty { + if path.path.segments.len() == 1 { + names.insert(path.path.segments[0].ident.to_string()); + } + } + } + } } } - None + names +} + +fn trait_bound_is_fn(bound: &syn::TypeParamBound) -> bool { + match bound { + syn::TypeParamBound::Trait(trait_bound) => { + trait_bound.path.segments.last().is_some_and(|segment| { + let ident = segment.ident.to_string(); + ident == "Fn" || ident == "FnMut" || ident == "FnOnce" + }) + } + _ => false, + } +} + +fn is_callable_type( + ty: &syn::Type, + callable_generics: &HashSet, + callable_type_aliases: &HashSet, +) -> bool { + match ty { + syn::Type::BareFn(_) => true, + syn::Type::ImplTrait(impl_trait) => impl_trait.bounds.iter().any(trait_bound_is_fn), + syn::Type::Path(path) => { + let name = path_to_string(&path.path); + let short = last_path_segment(&name); + callable_type_aliases.contains(&name) + || callable_type_aliases.contains(short) + || (path.path.segments.len() == 1 && callable_generics.contains(short)) + } + _ => false, + } +} + +struct Resolver<'a> { + known: HashMap, + nodes: &'a HashMap, + trait_aliases: HashMap, + full_trait_aliases: HashMap, + methods_by_name: HashMap>, + methods_by_type_and_name: HashMap<(String, String), Vec>, +} + +impl<'a> Resolver<'a> { + fn new(functions: &[String], nodes: &'a HashMap) -> Self { + let known: HashMap = functions + .iter() + .enumerate() + .map(|(index, name)| (name.clone(), index)) + .collect(); + let mut trait_alias_buckets: HashMap> = HashMap::new(); + let mut full_trait_aliases = HashMap::new(); + let mut methods_by_name: HashMap> = HashMap::new(); + let mut methods_by_type_and_name: HashMap<(String, String), Vec> = HashMap::new(); + + for (name, node) in nodes { + if let Some(method) = &node.method_name { + methods_by_name + .entry(method.clone()) + .or_default() + .push(name.clone()); + if let Some(self_type) = &node.self_type { + methods_by_type_and_name + .entry((self_type.clone(), method.clone())) + .or_default() + .push(name.clone()); + let short_self = short_type_name(self_type).to_string(); + methods_by_type_and_name + .entry((short_self.clone(), method.clone())) + .or_default() + .push(name.clone()); + if let Some(trait_name) = &node.trait_name { + for trait_alias in [ + trait_name.clone(), + last_path_segment(trait_name).to_string(), + ] { + trait_alias_buckets + .entry(format!("{trait_alias}::{method}")) + .or_default() + .push(name.clone()); + full_trait_aliases.insert( + format!("<{self_type} as {trait_alias}>::{method}"), + name.clone(), + ); + full_trait_aliases.insert( + format!("<{short_self} as {trait_alias}>::{method}"), + name.clone(), + ); + } + } + } + } + } + + let trait_aliases = trait_alias_buckets + .into_iter() + .filter_map(|(alias, targets)| { + if targets.len() == 1 { + Some((alias, targets[0].clone())) + } else { + None + } + }) + .collect(); + + Self { + known, + nodes, + trait_aliases, + full_trait_aliases, + methods_by_name, + methods_by_type_and_name, + } + } + + /// Resolve a callee name against known function names. + /// Tries normalized path prefixes first, then exact match, caller-local sibling match, + /// trait aliases, and suffix match. + fn resolve_path(&self, callee: &str, caller: &str) -> Option { + let candidates = self.normalized_path_candidates(callee, caller); + + for candidate in &candidates { + if self.known.contains_key(candidate) { + return Some(candidate.clone()); + } + } + + for candidate in &candidates { + if !candidate.contains("::") { + let caller_module = self.caller_module_parts(caller); + if !caller_module.is_empty() { + let sibling = qualify_name(&caller_module, candidate); + if self.known.contains_key(&sibling) { + return Some(sibling); + } + } + } + } + + for candidate in &candidates { + if let Some(resolved) = self.full_trait_aliases.get(candidate) { + return Some(resolved.clone()); + } + if let Some(resolved) = self.trait_aliases.get(candidate) { + return Some(resolved.clone()); + } + } + + for candidate in &candidates { + let suffix = format!("::{}", candidate); + let matches: Vec<&String> = + self.known.keys().filter(|k| k.ends_with(&suffix)).collect(); + if matches.len() == 1 { + return Some(matches[0].clone()); + } + + if candidate.contains("::") { + let matches: Vec<&String> = self + .known + .keys() + .filter(|k| k.ends_with(candidate)) + .collect(); + if matches.len() == 1 { + return Some(matches[0].clone()); + } + } + } + None + } + + fn normalized_path_candidates(&self, callee: &str, caller: &str) -> Vec { + let mut candidates = Vec::new(); + if let Some(rest) = callee.strip_prefix("crate::") { + candidates.push(rest.to_string()); + } else if let Some(rest) = callee.strip_prefix("self::") { + candidates.push(qualify_name(&self.caller_module_parts(caller), rest)); + } else if let Some(rest) = callee.strip_prefix("super::") { + let mut module_parts = self.caller_module_parts(caller); + let mut remaining = rest; + while let Some(next) = remaining.strip_prefix("super::") { + if !module_parts.is_empty() { + module_parts.pop(); + } + remaining = next; + } + if !module_parts.is_empty() { + module_parts.pop(); + } + candidates.push(qualify_name(&module_parts, remaining)); + } else if let Some(rest) = callee.strip_prefix("Self::") { + if let Some(self_type) = self + .nodes + .get(caller) + .and_then(|node| node.self_type.as_ref()) + { + candidates.push(format!("{self_type}::{rest}")); + } + } + candidates.push(callee.to_string()); + let mut seen = HashSet::new(); + candidates.retain(|candidate| seen.insert(candidate.clone())); + candidates + } + + fn caller_module_parts(&self, caller: &str) -> Vec { + let base_caller = caller.split("::{closure#").next().unwrap_or(caller); + if let Some(self_type) = self + .nodes + .get(base_caller) + .and_then(|node| node.self_type.as_ref()) + { + let mut parts: Vec = self_type.split("::").map(str::to_string).collect(); + parts.pop(); + return parts; + } + base_caller + .rsplit_once("::") + .map(|(parent, _)| parent.split("::").map(str::to_string).collect()) + .unwrap_or_default() + } + + fn resolve_method( + &self, + receiver_hint: Option<&str>, + method: &str, + caller: &str, + ) -> Option { + if let Some(receiver_hint) = receiver_hint { + let mut type_candidates = Vec::new(); + if !receiver_hint.contains("::") { + let caller_module = self.caller_module_parts(caller); + if !caller_module.is_empty() { + type_candidates.push(qualify_name(&caller_module, receiver_hint)); + } + } + type_candidates.push(receiver_hint.to_string()); + for node in self.nodes.values() { + if let Some(self_type) = &node.self_type { + if self_type == receiver_hint || short_type_name(self_type) == receiver_hint { + type_candidates.push(self_type.clone()); + } + } + } + let mut seen_types = HashSet::new(); + type_candidates.retain(|type_name| seen_types.insert(type_name.clone())); + + let method_key = method.to_string(); + for type_name in &type_candidates { + if let Some(methods) = self + .methods_by_type_and_name + .get(&(type_name.clone(), method_key.clone())) + { + if let Some(chosen) = choose_method_candidate(methods.clone(), self.nodes) { + return Some(chosen); + } + } + } + + let mut candidates = Vec::new(); + for type_name in type_candidates { + if let Some(methods) = self + .methods_by_type_and_name + .get(&(type_name, method_key.clone())) + { + candidates.extend(methods.iter().cloned()); + } + } + candidates.sort(); + candidates.dedup(); + return choose_method_candidate(candidates, self.nodes); + } + + self.methods_by_name + .get(method) + .and_then(|candidates| choose_method_candidate(candidates.clone(), self.nodes)) + } +} + +fn choose_method_candidate( + candidates: Vec, + nodes: &HashMap, +) -> Option { + if candidates.len() == 1 { + return candidates.into_iter().next(); + } + let inherent: Vec = candidates + .iter() + .filter(|name| { + nodes + .get(*name) + .is_some_and(|node| node.kind == FunctionKind::InherentMethod) + }) + .cloned() + .collect(); + if inherent.len() == 1 { + inherent.into_iter().next() + } else { + None + } +} + +fn concrete_generic_method_edges( + callee_record: &FunctionRecord, + arg_types: &[Option], + resolver: &Resolver<'_>, + caller: &str, +) -> Vec { + if callee_record.signature.generic_params.is_empty() { + return Vec::new(); + } + + let mut generic_bindings: HashMap = HashMap::new(); + for (param_name, param_type) in &callee_record.signature.param_types { + if !callee_record.signature.generic_params.contains(param_type) { + continue; + } + let Some(index) = callee_record.signature.param_indices.get(param_name) else { + continue; + }; + if let Some(Some(arg_type)) = arg_types.get(*index) { + generic_bindings.insert(param_type.clone(), arg_type.clone()); + } + } + + let mut out = Vec::new(); + for raw in &callee_record.raw_calls { + let RawCallSite::Method { + receiver_name, + receiver_hint, + method, + } = raw + else { + continue; + }; + + let concrete = receiver_name + .as_ref() + .and_then(|name| callee_record.signature.param_indices.get(name)) + .and_then(|index| arg_types.get(*index)) + .and_then(|arg_type| arg_type.clone()) + .or_else(|| { + receiver_hint + .as_ref() + .and_then(|hint| generic_bindings.get(hint).cloned()) + }); + let Some(concrete) = concrete else { + continue; + }; + if let Some(resolved) = resolver.resolve_method(Some(&concrete), method, caller) { + out.push(resolved); + } + } + out.sort(); + out.dedup(); + out } /// Detect cycles in the call graph using DFS. Returns the set of back-edges. @@ -322,47 +1494,142 @@ fn dfs_cycle( /// Build the call graph from all project files. pub fn build_call_graph(rs_files: &[PathBuf], project_path: &Path) -> CallGraph { - let mut all_functions: Vec<(String, Option)> = Vec::new(); - let mut all_edges: Vec = Vec::new(); + let mut records = Vec::new(); for file in rs_files { - let (funcs, edges) = extract_calls_from_file(file, project_path); - all_functions.extend(funcs); - all_edges.extend(edges); + records.extend(extract_calls_from_file(file, project_path)); } - // Build a set of known function names (with index for quick lookup) - let known: HashMap = all_functions + let functions: Vec = records.iter().map(|record| record.name.clone()).collect(); + let function_nodes: HashMap = records .iter() - .enumerate() - .map(|(i, (name, _))| (name.clone(), i)) + .map(|record| (record.name.clone(), record.node.clone())) + .collect(); + let entry_point_kinds: HashMap = records + .iter() + .filter_map(|record| { + record + .entry_kind + .clone() + .map(|kind| (record.name.clone(), kind)) + }) + .collect(); + let entry_points: Vec = records + .iter() + .filter(|record| entry_point_kinds.contains_key(&record.name)) + .map(|record| record.name.clone()) .collect(); - let functions: Vec = all_functions.iter().map(|(n, _)| n.clone()).collect(); - let entry_point_kinds: HashMap = all_functions + let resolver = Resolver::new(&functions, &function_nodes); + let callable_summaries: HashMap> = records .iter() - .filter_map(|(name, kind)| kind.clone().map(|k| (name.clone(), k))) + .filter(|record| !record.callable_param_invocations.is_empty()) + .map(|record| { + ( + record.name.clone(), + record.callable_param_invocations.clone(), + ) + }) .collect(); - let entry_points: Vec = all_functions + let records_by_name: HashMap = records .iter() - .filter(|(name, _)| entry_point_kinds.contains_key(name)) - .map(|(n, _)| n.clone()) + .map(|record| (record.name.clone(), record)) .collect(); - // Resolve edges against known functions let mut adjacency: HashMap> = HashMap::new(); - for edge in &all_edges { - if let Some(resolved) = resolve_callee(&edge.callee, &edge.caller, &known) { - if resolved != edge.caller { - adjacency - .entry(edge.caller.clone()) - .or_default() - .push(resolved); + let mut push_edge = |caller: &str, callee: String| { + if callee != caller { + adjacency + .entry(caller.to_string()) + .or_default() + .push(callee); + } + }; + + for record in &records { + for raw in &record.raw_calls { + match raw { + RawCallSite::Path { + path, + arg_functions, + arg_types, + } => { + if let Some(resolved) = resolver.resolve_path(path, &record.name) { + push_edge(&record.name, resolved.clone()); + if let Some(invoked_positions) = callable_summaries.get(&resolved) { + for position in invoked_positions { + if let Some(Some(arg_path)) = arg_functions.get(*position) { + if let Some(arg_function) = + resolver.resolve_path(arg_path, &record.name) + { + push_edge(&resolved, arg_function); + } + } + } + } + if let Some(callee_record) = records_by_name.get(&resolved) { + for callee in concrete_generic_method_edges( + callee_record, + arg_types, + &resolver, + &resolved, + ) { + push_edge(&resolved, callee); + } + if let Some(return_type) = &callee_record.signature.return_type { + if let Some(drop_impl) = + resolver.resolve_method(Some(return_type), "drop", &record.name) + { + if function_nodes + .get(&drop_impl) + .is_some_and(|node| node.kind == FunctionKind::DropImpl) + { + push_edge(&record.name, drop_impl); + } + } + } + } + } else { + for arg_path in arg_functions.iter().flatten() { + if let Some(arg_function) = + resolver.resolve_path(arg_path, &record.name) + { + push_edge(&record.name, arg_function); + } + } + } + } + RawCallSite::Method { + receiver_name: _, + receiver_hint, + method, + } => { + if let Some(resolved) = + resolver.resolve_method(receiver_hint.as_deref(), method, &record.name) + { + push_edge(&record.name, resolved); + } + } + RawCallSite::LocalCallable { name } => { + let _ = name; + } + RawCallSite::ImplicitDrop { type_name } => { + if let Some(resolved) = + resolver.resolve_method(Some(type_name), "drop", &record.name) + { + if function_nodes + .get(&resolved) + .is_some_and(|node| node.kind == FunctionKind::DropImpl) + { + push_edge(&record.name, resolved); + } + } + } } } } - // Deduplicate adjacency lists while preserving order + // Deduplicate adjacency lists while preserving order. for callees in adjacency.values_mut() { let mut seen = HashSet::new(); callees.retain(|c| seen.insert(c.clone())); @@ -372,6 +1639,7 @@ pub fn build_call_graph(rs_files: &[PathBuf], project_path: &Path) -> CallGraph CallGraph { functions, + function_nodes, adjacency, entry_points, entry_point_kinds, @@ -775,6 +2043,369 @@ mod tests { root } + fn assert_edge(graph: &CallGraph, caller: &str, callee: &str) { + assert!( + graph + .adjacency + .get(caller) + .is_some_and(|callees| callees.contains(&callee.to_string())), + "{caller} should call {callee}: {:?}", + graph.adjacency.get(caller) + ); + } + + #[test] + fn flow_mvp_chat_normalizes_qualified_direct_calls() { + let root = temp_project("mvp-qualified"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +fn root_helper() {} +fn outer(_value: ()) {} +fn inner() {} +mod nested { + pub fn target() {} + pub mod child { + pub fn local_helper() {} + pub fn exercise() { + super::target(); + crate::root_helper(); + self::local_helper(); + outer(crate::inner()); + } + } +} +struct RuntimeConfigProfile; +impl RuntimeConfigProfile { + fn default_provider() {} + fn exercise() { Self::default_provider(); } +} +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert_edge(&graph, "nested::child::exercise", "nested::target"); + assert_edge( + &graph, + "nested::child::exercise", + "nested::child::local_helper", + ); + assert_edge(&graph, "nested::child::exercise", "root_helper"); + assert_edge(&graph, "nested::child::exercise", "outer"); + assert_edge(&graph, "nested::child::exercise", "inner"); + assert_edge( + &graph, + "RuntimeConfigProfile::exercise", + "RuntimeConfigProfile::default_provider", + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn flow_mvp_chat_tracks_wrapper_function_item_seam() { + let root = temp_project("mvp-seam"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +fn ensure_orch_binary() -> bool { + ensure_orch_binary_with("config", "root", "default-orch", run_status) +} +fn ensure_orch_binary_with( + _config: &str, + _root: &str, + _default_orch: &str, + run_status_fn: fn() -> bool, +) -> bool { + run_status_fn() +} +fn run_status() -> bool { true } +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert_edge(&graph, "ensure_orch_binary", "ensure_orch_binary_with"); + assert_edge(&graph, "ensure_orch_binary_with", "run_status"); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn flow_mvp_chat_tracks_function_pointer_type_alias_seam() { + let root = temp_project("mvp-alias-seam"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +type RunStatusFn = fn(&str, &[&str], &str) -> bool; +fn ensure_orch_binary() -> bool { + ensure_orch_binary_with(run_status) +} +fn ensure_orch_binary_with(run_status_fn: RunStatusFn) -> bool { + run_status_fn("cargo", &["build"], "build orchestrator") +} +fn run_status(_program: &str, _args: &[&str], _label: &str) -> bool { true } +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert_edge(&graph, "ensure_orch_binary", "ensure_orch_binary_with"); + assert_edge(&graph, "ensure_orch_binary_with", "run_status"); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn flow_mvp_chat_walks_vec_macro_and_array_method_chains() { + let root = temp_project("mvp-vec-macro"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +struct RuntimeConfigProfile; +struct RelayMode; +struct Config { + config_profile: RuntimeConfigProfile, + relay_mode: RelayMode, +} +impl RuntimeConfigProfile { + fn as_str(self) -> &'static str { "local" } +} +impl RelayMode { + fn as_str(&self) -> &'static str { "default" } +} +fn relay_mode_env_value(_mode: &RelayMode) -> &'static str { "default" } +impl Config { + fn orchestrator_cli_args(&self) { + let mut args = vec![ + "--runtime-config".to_owned(), + self.config_profile.as_str().to_owned(), + self.relay_mode.as_str().to_owned(), + ]; + args.extend([ + "--relay-mode".to_owned(), + relay_mode_env_value(&self.relay_mode).to_owned(), + ]); + } +} +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert_edge( + &graph, + "Config::orchestrator_cli_args", + "RuntimeConfigProfile::as_str", + ); + assert_edge(&graph, "Config::orchestrator_cli_args", "RelayMode::as_str"); + assert_edge( + &graph, + "Config::orchestrator_cli_args", + "relay_mode_env_value", + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn flow_mvp_chat_prefers_receiver_type_in_caller_module_when_short_names_collide() { + let root = temp_project("mvp-short-collision"); + let bin_dir = root.join("src/bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let main = bin_dir.join("mvp_chat.rs"); + fs::write( + &main, + r#" +mod other { + pub struct RuntimeConfigProfile; + impl RuntimeConfigProfile { + pub fn as_str(&self) -> &'static str { "other" } + } + pub fn relay_mode_env_value() {} +} +struct RuntimeConfigProfile; +struct Config { config_profile: RuntimeConfigProfile } +impl RuntimeConfigProfile { + fn as_str(&self) -> &'static str { "local" } +} +fn relay_mode_env_value() {} +impl Config { + fn orchestrator_cli_args(&self) { + self.config_profile.as_str(); + relay_mode_env_value(); + } +} +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert_edge( + &graph, + "bin::mvp_chat::Config::orchestrator_cli_args", + "bin::mvp_chat::RuntimeConfigProfile::as_str", + ); + assert_edge( + &graph, + "bin::mvp_chat::Config::orchestrator_cli_args", + "bin::mvp_chat::relay_mode_env_value", + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn flow_mvp_chat_adds_drop_edges_for_self_returning_associated_results() { + let root = temp_project("mvp-self-return-drop"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +struct OrchChild; +impl OrchChild { + fn spawn() -> Result { Ok(OrchChild) } + fn shutdown(&mut self, _wait: bool) {} +} +impl Drop for OrchChild { + fn drop(&mut self) { self.shutdown(false); } +} +fn exercise() { + let _child = OrchChild::spawn().expect("spawn"); +} +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert_edge(&graph, "exercise", "OrchChild::spawn"); + assert_edge(&graph, "exercise", "OrchChild::drop"); + assert_edge(&graph, "OrchChild::drop", "OrchChild::shutdown"); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn flow_mvp_chat_resolves_mutable_receiver_and_nested_calls() { + let root = temp_project("mvp-mutable"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +struct Worker; +impl Worker { + fn run(&mut self, _value: usize) { method_leaf(); } +} +fn inner() -> usize { free_leaf(); 1 } +fn free_leaf() {} +fn method_leaf() {} +fn exercise(mut worker: Worker) { worker.run(inner()); } +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert_edge(&graph, "exercise", "Worker::run"); + assert_edge(&graph, "exercise", "inner"); + assert_edge(&graph, "inner", "free_leaf"); + assert_edge(&graph, "Worker::run", "method_leaf"); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn flow_mvp_chat_walks_nested_calls_in_struct_array_constructor_and_macro_args() { + let root = temp_project("mvp-nested"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +macro_rules! pass { ($($tt:tt)*) => { $($tt)* }; } +struct Config { field: usize } +fn consume(_value: usize) {} +fn build(value: usize) -> usize { value } +fn arg_leaf() -> usize { 1 } +fn field_leaf() -> usize { 2 } +fn array_leaf() -> usize { 3 } +fn macro_leaf() -> usize { 4 } +fn exercise() { + consume(build(arg_leaf())); + let _config = Config { field: build(field_leaf()) }; + let _items = [array_leaf()]; + pass!(consume(build(macro_leaf()))); +} +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + for callee in [ + "consume", + "build", + "arg_leaf", + "field_leaf", + "array_leaf", + "macro_leaf", + ] { + assert_edge(&graph, "exercise", callee); + } + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn flow_mvp_chat_adds_drop_edges_for_temporary_constructed_values() { + let root = temp_project("mvp-drop-temporary"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +struct Command; +struct OrchChild(Command); +impl OrchChild { + fn shutdown(&mut self, _wait: bool) {} + fn consume(self) {} +} +impl Drop for OrchChild { + fn drop(&mut self) { self.shutdown(false); } +} +fn consume(_child: OrchChild) {} +fn exercise() { + let _items = [OrchChild(Command), OrchChild(Command)]; + (OrchChild(Command),); + OrchChild(Command); + consume(OrchChild(Command)); + OrchChild(Command).consume(); +} +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert_edge(&graph, "exercise", "OrchChild::drop"); + assert_edge(&graph, "exercise", "consume"); + assert_edge(&graph, "consume", "OrchChild::drop"); + assert_edge(&graph, "exercise", "OrchChild::consume"); + assert_edge(&graph, "OrchChild::consume", "OrchChild::drop"); + assert_edge(&graph, "OrchChild::drop", "OrchChild::shutdown"); + + fs::remove_dir_all(root).unwrap(); + } + #[test] fn resolves_unqualified_sibling_call_when_suffix_is_ambiguous() { let root = temp_project("sibling-run"); @@ -829,4 +2460,449 @@ fn main() {} fs::remove_dir_all(root).unwrap(); } + + #[test] + fn resolves_trait_impl_method_when_receiver_type_known() { + let root = temp_project("trait-impl-run"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +trait Runner { + fn run(&self); +} + +struct Worker; + +impl Runner for Worker { + fn run(&self) { + step(); + } +} + +fn step() {} + +fn exercise(worker: Worker) { + worker.run(); +} + +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + let callees = graph + .adjacency + .get("exercise") + .expect("exercise should have outgoing edges"); + assert!( + callees.contains(&"Worker::run".to_string()), + "callee list did not include trait impl method: {callees:?}" + ); + assert!( + graph + .adjacency + .get("Worker::run") + .is_some_and(|callees| callees.contains(&"step".to_string())), + "trait method body should still resolve direct calls" + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn resolves_std_read_bufread_write_impl_methods_when_receiver_type_known() { + let root = temp_project("std-traits-run"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +use std::io::{self, BufRead, Read, Write}; + +struct Io; + +impl Read for Io { + fn read(&mut self, _buf: &mut [u8]) -> io::Result { Ok(0) } +} + +impl BufRead for Io { + fn fill_buf(&mut self) -> io::Result<&[u8]> { Ok(&[]) } + fn consume(&mut self, _amt: usize) {} +} + +impl Write for Io { + fn write(&mut self, _buf: &[u8]) -> io::Result { Ok(0) } + fn flush(&mut self) -> io::Result<()> { Ok(()) } +} + +fn exercise(mut io: Io) { + let _ = io.read(&mut []); + let _ = io.fill_buf(); + let _ = io.write(b"x"); + let _ = io.flush(); +} + +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + let callees = graph + .adjacency + .get("exercise") + .expect("exercise should have outgoing edges"); + for expected in ["Io::read", "Io::fill_buf", "Io::write", "Io::flush"] { + assert!( + callees.contains(&expected.to_string()), + "callee list did not include {expected}: {callees:?}" + ); + } + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn adds_synthetic_drop_edge_for_owned_values() { + let root = temp_project("drop-run"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +struct Resource; + +impl Drop for Resource { + fn drop(&mut self) { + cleanup(); + } +} + +fn cleanup() {} + +fn make_resource() { + let _resource = Resource; +} + +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert!( + graph + .adjacency + .get("make_resource") + .is_some_and(|callees| callees.contains(&"Resource::drop".to_string())), + "make_resource should have synthetic Resource::drop edge: {:?}", + graph.adjacency.get("make_resource") + ); + assert!( + graph + .adjacency + .get("Resource::drop") + .is_some_and(|callees| callees.contains(&"cleanup".to_string())), + "drop body should still resolve cleanup" + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn does_not_credit_uninvoked_closure_body() { + let root = temp_project("closure-run"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +fn hidden() {} + +fn builds_closure() { + let _callback = || hidden(); +} + +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert!( + graph + .adjacency + .get("builds_closure") + .is_none_or(|callees| !callees.contains(&"hidden".to_string())), + "closure body should not be attributed to enclosing function: {:?}", + graph.adjacency.get("builds_closure") + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn tracks_simple_function_pointer_parameter_seam() { + let root = temp_project("seam-run"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +fn run(callback: fn()) { + callback(); +} + +fn helper() {} + +fn test_root() { + run(helper); +} + +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert!( + graph + .adjacency + .get("test_root") + .is_some_and(|callees| callees.contains(&"run".to_string())), + "test_root should call run" + ); + assert!( + graph + .adjacency + .get("run") + .is_some_and(|callees| callees.contains(&"helper".to_string())), + "run should inherit helper edge from callable seam: {:?}", + graph.adjacency.get("run") + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn resolves_field_receiver_inherent_methods_and_direct_helpers() { + let root = temp_project("field-receiver-run"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +struct RuntimeConfigProfile; + +impl RuntimeConfigProfile { + fn as_str(&self) -> &'static str { "docker" } + fn default_provider() -> ProviderKind { ProviderKind::Docker } +} + +enum ProviderKind { Docker } + +struct Config { + profile: RuntimeConfigProfile, +} + +fn relay_mode_env_value() -> &'static str { "1" } + +impl Config { + fn orchestrator_cli_args(&self) { + self.profile.as_str(); + RuntimeConfigProfile::default_provider(); + relay_mode_env_value(); + } +} + +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + let callees = graph + .adjacency + .get("Config::orchestrator_cli_args") + .expect("orchestrator_cli_args should have outgoing edges"); + for expected in [ + "RuntimeConfigProfile::as_str", + "RuntimeConfigProfile::default_provider", + "relay_mode_env_value", + ] { + assert!( + callees.contains(&expected.to_string()), + "callee list did not include {expected}: {callees:?}" + ); + } + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn resolves_generic_trait_bound_receiver_calls_from_concrete_arguments() { + let root = temp_project("generic-trait-run"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +trait VastAiApproval { + fn ask(&mut self) -> bool; +} + +struct StdinVastAiApproval; + +impl VastAiApproval for StdinVastAiApproval { + fn ask(&mut self) -> bool { + confirm_vastai_if_needed() + } +} + +fn confirm_vastai_if_needed() -> bool { true } + +fn confirm(approval: &mut P) -> bool { + approval.ask() +} + +fn exercise() { + let mut approval = StdinVastAiApproval; + confirm(&mut approval); +} + +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert!( + graph + .adjacency + .get("confirm") + .is_some_and(|callees| callees.contains(&"StdinVastAiApproval::ask".to_string())), + "confirm should resolve generic receiver to concrete trait impl: {:?}", + graph.adjacency.get("confirm") + ); + assert!( + graph + .adjacency + .get("StdinVastAiApproval::ask") + .is_some_and(|callees| callees.contains(&"confirm_vastai_if_needed".to_string())), + "trait impl body should still resolve helper" + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn adds_drop_edges_for_owned_params_and_tuple_constructors() { + let root = temp_project("drop-param-run"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +struct OrchChild(Command); +struct Command; + +impl Drop for OrchChild { + fn drop(&mut self) { + self.shutdown(false); + } +} + +impl OrchChild { + fn shutdown(&mut self, _wait: bool) {} +} + +fn consume(_child: OrchChild) {} + +fn exercise(cmd: Command) { + let child = OrchChild(cmd); + consume(child); +} + +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + for caller in ["exercise", "consume"] { + assert!( + graph + .adjacency + .get(caller) + .is_some_and(|callees| callees.contains(&"OrchChild::drop".to_string())), + "{caller} should have synthetic OrchChild::drop edge: {:?}", + graph.adjacency.get(caller) + ); + } + assert!( + graph + .adjacency + .get("OrchChild::drop") + .is_some_and(|callees| callees.contains(&"OrchChild::shutdown".to_string())), + "drop should call shutdown: {:?}", + graph.adjacency.get("OrchChild::drop") + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn tracks_callbacks_registered_with_external_apis_and_closure_args() { + let root = temp_project("callback-run"); + let main = root.join("src/main.rs"); + fs::write( + &main, + r#" +fn request_stop() {} +fn helper() {} + +fn run(callback: impl Fn()) { + callback(); +} + +fn install_signal_handler() { + unsafe { + libc::signal(15, request_stop as *const () as usize); + } +} + +fn exercise() { + install_signal_handler(); + run(|| helper()); +} + +fn main() {} +"#, + ) + .unwrap(); + + let graph = build_call_graph(&[main], &root); + assert!( + graph + .adjacency + .get("install_signal_handler") + .is_some_and(|callees| callees.contains(&"request_stop".to_string())), + "external callback registration should reach request_stop: {:?}", + graph.adjacency.get("install_signal_handler") + ); + assert!( + graph + .adjacency + .get("run") + .is_some_and(|callees| callees.iter().any(|callee| callee.contains("{closure#"))), + "callable parameter should reach passed closure: {:?}", + graph.adjacency.get("run") + ); + assert!( + graph + .adjacency + .iter() + .any(|(caller, callees)| caller.contains("{closure#") + && callees.contains(&"helper".to_string())), + "closure record should reach helper: {:?}", + graph.adjacency + ); + + fs::remove_dir_all(root).unwrap(); + } } diff --git a/src/loc.rs b/src/loc.rs index f7611dd..2d37ab2 100644 --- a/src/loc.rs +++ b/src/loc.rs @@ -1,10 +1,11 @@ use colored::Colorize; use serde::Serialize; +use std::collections::HashSet; use std::path::{Path, PathBuf}; -use crate::render; - +use crate::{ast_parser, datapaths, file_metrics, flow, render}; /// Per-file line count data. +#[derive(Debug, Clone, Serialize)] #[allow(dead_code)] pub struct FileLocStats { pub path: PathBuf, @@ -55,6 +56,24 @@ struct LocJson { directory_breakdown: Vec, } +#[derive(Debug, Clone, Serialize)] +pub struct FileLineSpan { + pub function: String, + pub line_start: usize, + pub line_end: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileProjectedLineReachabilityReport { + pub file: String, + pub total_lines: usize, + pub code_lines: usize, + pub projected_reachable_lines: usize, + pub projected_unreachable_lines: usize, + pub reachable_spans: Vec, + pub unreachable_spans: Vec, +} + /// Classify a single trimmed line as code or non-code, tracking block comment state. /// Returns `(is_code, new_in_block_comment)`. fn classify_line(trimmed: &str, in_block_comment: bool) -> (bool, bool) { @@ -90,7 +109,7 @@ fn classify_line(trimmed: &str, in_block_comment: bool) -> (bool, bool) { } /// Count lines in a single file. Code lines exclude blank lines and comment-only lines. -fn count_lines(path: &Path) -> Option { +pub fn count_file_lines(path: &Path) -> Option { let content = std::fs::read_to_string(path).ok()?; let total_lines = content.lines().count(); let mut code_lines = 0usize; @@ -111,6 +130,93 @@ fn count_lines(path: &Path) -> Option { }) } +fn count_lines(path: &Path) -> Option { + count_file_lines(path) +} + +fn code_lines_in_spans(path: &Path, spans: &[(usize, usize)]) -> HashSet { + if spans.is_empty() { + return HashSet::new(); + } + + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(_) => return HashSet::new(), + }; + + let mut covered = HashSet::new(); + let mut in_block_comment = false; + + for (index, line) in content.lines().enumerate() { + let line_number = index + 1; + let (is_code, new_in_block) = classify_line(line.trim(), in_block_comment); + in_block_comment = new_in_block; + if !is_code { + continue; + } + + if spans + .iter() + .any(|(start, end)| line_number >= *start && line_number <= *end) + { + covered.insert(line_number); + } + } + + covered +} + +pub fn analyze_file_projected_line_reachability( + file: &Path, + file_functions: &file_metrics::FileFunctionIndex, + reachable_functions: &HashSet, + file_display_path: &str, +) -> Option { + let file_stats = count_file_lines(file)?; + let mut reachable_spans = Vec::new(); + let mut unreachable_spans = Vec::new(); + let mut production_ranges = Vec::new(); + let mut reachable_ranges = Vec::new(); + + for function in &file_functions.functions { + if function.is_test + || function.is_bench + || function.is_test_support + || function.is_binary_wrapper_main + { + continue; + } + + production_ranges.push((function.line_start, function.line_end)); + let span = FileLineSpan { + function: function.display_name.clone(), + line_start: function.line_start, + line_end: function.line_end, + }; + + if reachable_functions.contains(&function.graph_name) { + reachable_ranges.push((function.line_start, function.line_end)); + reachable_spans.push(span); + } else { + unreachable_spans.push(span); + } + } + + let code_lines = code_lines_in_spans(file, &production_ranges).len(); + let projected_reachable_lines = code_lines_in_spans(file, &reachable_ranges).len(); + let projected_unreachable_lines = code_lines.saturating_sub(projected_reachable_lines); + + Some(FileProjectedLineReachabilityReport { + file: file_display_path.to_string(), + total_lines: file_stats.total_lines, + code_lines, + projected_reachable_lines, + projected_unreachable_lines, + reachable_spans, + unreachable_spans, + }) +} + /// Count lines for all files and compute aggregate statistics. pub fn analyze_files(files: &[PathBuf]) -> (Vec, Option) { let mut file_stats: Vec = files.iter().filter_map(|f| count_lines(f)).collect(); @@ -356,6 +462,117 @@ pub fn render_loc_json(files: &[PathBuf], project_path: &Path) { println!("{}", serde_json::to_string(&output).unwrap()); } +pub fn render_loc_file(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) { + let report = analyze_file_projected_line_reachability_from_files(rs_files, project_path, file); + render_file_projected_line_reachability_report(&report, verbose); +} + +pub fn render_loc_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) { + if let Some(report) = + analyze_file_projected_line_reachability_from_files(rs_files, project_path, file) + { + println!("{}", serde_json::to_string(&report).unwrap()); + } else { + println!("null"); + } +} + +pub fn analyze_file_projected_line_reachability_from_files( + rs_files: &[PathBuf], + project_path: &Path, + file: &Path, +) -> Option { + let symbols = ast_parser::parse_project(rs_files); + let graph = flow::build_call_graph(rs_files, project_path); + let file_functions = + file_metrics::selected_file_functions(&symbols, &graph, project_path, file); + let file_display_path = relative_path_string(project_path, file); + let static_reachability = + datapaths::analyze_file_static_reachability(&graph, &file_functions, &file_display_path); + let reachable_functions: HashSet = static_reachability + .reachable_functions + .iter() + .cloned() + .collect(); + analyze_file_projected_line_reachability( + file, + &file_functions, + &reachable_functions, + &file_display_path, + ) +} + +pub fn render_file_projected_line_reachability_report( + report: &Option, + verbose: bool, +) { + render::section_header("Projected line reachability"); + + if verbose { + render::verbose_block(&[ + "Physical lines count every source line in the selected file.", + "Code lines exclude blank/comment-only lines and test/support/wrapper spans for selected-file production accounting.", + "Projected reachable lines are production code lines inside functions statically reached from project tests/benches.", + ]); + } + + let Some(report) = report else { + println!(" {}", "Unable to read selected file.".yellow()); + return; + }; + + let pct = if report.code_lines == 0 { + 0.0 + } else { + report.projected_reachable_lines as f64 / report.code_lines as f64 * 100.0 + }; + + println!(" file {}", report.file.bold()); + println!(" physical lines {}", report.total_lines); + println!(" production code lines {}", report.code_lines); + println!( + " statically reachable lines {} / {} ({:.1}%)", + report.projected_reachable_lines, report.code_lines, pct + ); + println!( + " not statically reachable lines {}", + report.projected_unreachable_lines + ); + + println!(); + println!(" {}", "reachable function spans:".bold()); + if report.reachable_spans.is_empty() { + println!(" {}", "".dimmed()); + } else { + for span in &report.reachable_spans { + println!( + " {} lines {}-{}", + span.function, span.line_start, span.line_end + ); + } + } + + println!(); + println!(" {}", "unreachable function spans:".bold().yellow()); + if report.unreachable_spans.is_empty() { + println!(" {}", "".dimmed()); + } else { + for span in &report.unreachable_spans { + println!( + " {} lines {}-{}", + span.function, span.line_start, span.line_end + ); + } + } +} + +fn relative_path_string(project_path: &Path, path: &Path) -> String { + path.strip_prefix(project_path) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + // ── Treemap Visualization ──────────────────────────────────────────── /// A node in the directory tree used for treemap rendering. diff --git a/src/main.rs b/src/main.rs index 120da1d..63b6733 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,9 @@ mod ast_parser; mod call_trace; mod circuits; +mod cluster; mod complexity; +mod coverage; mod datapaths; mod dead_code; mod deps; @@ -9,6 +11,8 @@ mod diagnostics; mod discovery; mod dist; mod dump; +mod file_metrics; +mod file_summary; mod flow; mod graph; mod guide; @@ -78,6 +82,14 @@ enum Commands { #[arg(long, name = "PATH::FUNCTION")] entry: Option, }, + /// Dynamic line and branch coverage from Rust source-based coverage data. + Coverage { + /// Parse an existing llvm-cov export JSON from CSTAT_LLVM_COV_EXPORT_JSON instead of running tests. + #[arg(long)] + no_run: bool, + }, + /// Value-cluster transcript from AST def-use structure. + Cluster, /// Opt-in legacy and exploratory analyses Advanced { #[command(subcommand)] @@ -150,10 +162,8 @@ fn main() { } let verbose = cli.verbose; - let project_path = &cli.path; - - let rs_files = match discovery::discover_rs_files(project_path) { - Ok(files) => files, + let target = match discovery::resolve_target(&cli.path) { + Ok(target) => target, Err(e) => { if json { let err = serde_json::json!({"error": format!("{}", e)}); @@ -164,12 +174,130 @@ fn main() { std::process::exit(1); } }; + let project_path = &target.project_path; + let rs_files = discovery::files_for_project(&target); let command = cli.command.unwrap_or(Commands::Summary); - let project_path = project_path - .canonicalize() - .unwrap_or_else(|_| project_path.to_path_buf()); + if let Some(file) = discovery::selected_file(&target) { + let project_rs_files = discovery::files_for_project(&target); + let target_rs_files = discovery::files_for_target(&target); + match command { + Commands::Summary | Commands::Report => { + if json { + file_summary::render_file_summary_json(project_rs_files, project_path, file); + } else { + file_summary::render_file_summary( + project_rs_files, + project_path, + file, + verbose, + ); + } + } + Commands::Loc { .. } => { + if json { + loc::render_loc_file_json(project_rs_files, project_path, file); + } else { + loc::render_loc_file(project_rs_files, project_path, file, verbose); + } + } + Commands::Symbols => { + if json { + symbols::render_symbols_file_json(project_rs_files, project_path, file); + } else { + symbols::render_symbols_file(project_rs_files, project_path, file, verbose); + } + } + Commands::Complexity => { + complexity::run(target_rs_files, project_path, json, verbose); + } + Commands::Cluster => { + if json { + cluster::render_cluster_file_json(project_rs_files, project_path, file); + } else { + cluster::render_cluster_file(project_rs_files, project_path, file, verbose); + } + } + Commands::Deps { .. } => { + if json { + deps::render_deps_file_json(project_rs_files, project_path, file); + } else { + deps::render_deps_file(project_rs_files, project_path, file, verbose); + } + } + Commands::DeadCode => { + if json { + dead_code::render_dead_code_file_json(project_rs_files, project_path, file); + } else { + dead_code::render_dead_code_file(project_rs_files, project_path, file, verbose); + } + } + Commands::TestReachability => { + if json { + datapaths::render_test_reachability_file_json( + project_rs_files, + project_path, + file, + ); + } else { + datapaths::render_test_reachability_file( + project_rs_files, + project_path, + file, + verbose, + ); + } + } + Commands::CallTrace { entry } => { + if let Err(message) = call_trace::run_file( + project_rs_files, + project_path, + file, + entry.as_deref(), + json, + verbose, + ) { + if json { + let err = serde_json::json!({"error": message}); + println!("{}", serde_json::to_string(&err).unwrap()); + } else { + eprintln!("Error: {}", message); + } + std::process::exit(1); + } + } + Commands::Coverage { no_run } => { + if let Err(message) = coverage::render_coverage( + project_rs_files, + project_path, + Some(file), + no_run, + json, + ) { + if json { + let err = serde_json::json!({"error": message}); + println!("{}", serde_json::to_string(&err).unwrap()); + } else { + eprintln!("Error: {}", message); + } + std::process::exit(1); + } + } + Commands::Advanced { .. } => { + let message = + "advanced commands require a project directory; pass --path "; + if json { + let err = serde_json::json!({"error": message}); + println!("{}", serde_json::to_string(&err).unwrap()); + } else { + eprintln!("Error: {}", message); + } + std::process::exit(1); + } + } + return; + } match command { Commands::Summary | Commands::Report => { @@ -196,6 +324,9 @@ fn main() { Commands::Complexity => { complexity::run(&rs_files, &project_path, json, verbose); } + Commands::Cluster => { + cluster::render_cluster(&rs_files, &project_path, json, verbose); + } Commands::Deps { top } => { if json { deps::render_deps_json(&rs_files, &project_path); @@ -230,6 +361,19 @@ fn main() { std::process::exit(1); } } + Commands::Coverage { no_run } => { + if let Err(message) = + coverage::render_coverage(&rs_files, &project_path, None, no_run, json) + { + if json { + let err = serde_json::json!({"error": message}); + println!("{}", serde_json::to_string(&err).unwrap()); + } else { + eprintln!("Error: {}", message); + } + std::process::exit(1); + } + } Commands::Advanced { command } => match command { AdvancedCommands::Summary => { if json { diff --git a/src/summary/mod.rs b/src/summary/mod.rs index 78bfdf5..2840c40 100644 --- a/src/summary/mod.rs +++ b/src/summary/mod.rs @@ -55,7 +55,7 @@ pub fn render_summary(rs_files: &[PathBuf], project_path: &Path, verbose: bool) render_connectome_section(&dep_analysis, term_w, verbose); render_distributions_section(&func_complexities, verbose); render_centrality_section(rs_files, &project_path_canon, verbose); - render_datapath_section(rs_files, &project_path_canon, verbose); + render_reachability_section(rs_files, &project_path_canon, verbose); render_circuits_section(rs_files, &project_path_canon, verbose); render_stats_footer( &agg_loc, @@ -385,22 +385,22 @@ fn render_centrality_section(rs_files: &[PathBuf], project_path_canon: &Path, ve } } -/// Render the datapath coverage section. -fn render_datapath_section(rs_files: &[PathBuf], project_path_canon: &Path, verbose: bool) { - render::section_header("Datapath Coverage"); +/// Render the static entrypoint reachability section. +fn render_reachability_section(rs_files: &[PathBuf], project_path_canon: &Path, verbose: bool) { + render::section_header("Static entrypoint reachability"); if verbose { render::verbose_block(&[ - "Percentage of functions reachable from entry points via call graph traversal.", - "Entry points are main() and #[test] functions. Reachability is computed via BFS.", - "Coverage = reachable / total. Cold functions = unreachable = dead code candidates.", + "Percentage of functions reachable from entry points via static call graph traversal.", + "Entry points are main(), #[test] functions, and benchmarks. Reachability is computed via BFS.", + "Reachability = reachable / total. Cold functions = unreachable = dead code candidates.", "Color: green > 80%, yellow > 50%, red ≤ 50%.", ]); } - match datapaths::datapath_summary(rs_files, project_path_canon) { - Some(ds) => render_datapath_coverage(&ds), - None => println!(" {}", "No datapath data available.".dimmed()), + match datapaths::entrypoint_reachability_summary(rs_files, project_path_canon) { + Some(ds) => render_entrypoint_reachability(&ds), + None => println!(" {}", "No reachability data available.".dimmed()), } } @@ -513,8 +513,8 @@ struct SummaryGraphJson { } #[derive(Serialize)] -struct SummaryDatapathJson { - coverage_pct: f64, +struct SummaryReachabilityJson { + reachable_pct: f64, total_functions: usize, reachable_count: usize, cold_count: usize, @@ -535,7 +535,7 @@ struct SummaryJson { complexity: Option, deps: SummaryDepsJson, graph: Option, - datapaths: Option, + reachability: Option, circuits: Option, } @@ -610,12 +610,14 @@ pub fn render_summary_json(rs_files: &[PathBuf], project_path: &Path) { top_clustered: gs.top_clustered, }); - let datapaths_json = - datapaths::datapath_summary(rs_files, &project_path_canon).map(|ds| SummaryDatapathJson { - coverage_pct: ds.coverage_pct, - total_functions: ds.total_functions, - reachable_count: ds.reachable_count, - cold_count: ds.cold_count, + let reachability_json = + datapaths::entrypoint_reachability_summary(rs_files, &project_path_canon).map(|ds| { + SummaryReachabilityJson { + reachable_pct: ds.reachable_pct, + total_functions: ds.total_functions, + reachable_count: ds.reachable_count, + cold_count: ds.cold_count, + } }); let circuits_json = @@ -632,7 +634,7 @@ pub fn render_summary_json(rs_files: &[PathBuf], project_path: &Path) { complexity: complexity_json, deps: deps_json, graph: graph_json, - datapaths: datapaths_json, + reachability: reachability_json, circuits: circuits_json, }; diff --git a/src/summary/sections.rs b/src/summary/sections.rs index a3c6179..f94fbe5 100644 --- a/src/summary/sections.rs +++ b/src/summary/sections.rs @@ -119,25 +119,25 @@ pub(super) fn render_graph_centrality(gs: &graph::GraphSummary) { } } -/// Render datapath coverage section. -pub(super) fn render_datapath_coverage(ds: &datapaths::DatapathSummary) { - let coverage_color = if ds.coverage_pct > 80.0 { - format!("{:.1}%", ds.coverage_pct) +/// Render static entrypoint reachability section. +pub(super) fn render_entrypoint_reachability(ds: &datapaths::EntrypointReachabilitySummary) { + let reachability_color = if ds.reachable_pct > 80.0 { + format!("{:.1}%", ds.reachable_pct) .green() .bold() .to_string() - } else if ds.coverage_pct > 50.0 { - format!("{:.1}%", ds.coverage_pct) + } else if ds.reachable_pct > 50.0 { + format!("{:.1}%", ds.reachable_pct) .yellow() .bold() .to_string() } else { - format!("{:.1}%", ds.coverage_pct).red().bold().to_string() + format!("{:.1}%", ds.reachable_pct).red().bold().to_string() }; println!( " {} {} reachable from entry points", - "Coverage:".bold(), - coverage_color, + "Reachability:".bold(), + reachability_color, ); println!( " {} reachable, {} cold (of {} total)", diff --git a/src/symbols.rs b/src/symbols.rs index e883f35..4e551ec 100644 --- a/src/symbols.rs +++ b/src/symbols.rs @@ -34,6 +34,38 @@ pub struct SymbolReport { pub files: Vec, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FileSymbolKind { + Fn, + Struct, + Enum, + Trait, + Const, + Static, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileSymbolRow { + pub kind: FileSymbolKind, + pub symbol: String, + pub line_start: Option, + pub line_end: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FileSymbolReport { + pub file: String, + pub total: usize, + pub functions: usize, + pub structs: usize, + pub enums: usize, + pub traits: usize, + pub consts: usize, + pub statics: usize, + pub symbols: Vec, +} + pub fn analyze_symbols(symbols: &ProjectSymbols, project_path: &Path) -> SymbolReport { let mut files: Vec = symbols .files @@ -82,6 +114,97 @@ pub fn analyze_files(rs_files: &[PathBuf], project_path: &Path) -> SymbolReport analyze_symbols(&symbols, project_path) } +pub fn analyze_symbols_file( + symbols: &ast_parser::ProjectSymbols, + project_path: &Path, + file: &Path, +) -> FileSymbolReport { + let file_display = relative_path_string(project_path, file); + let Some(file_symbols) = symbols.files.iter().find(|entry| entry.path == file) else { + return FileSymbolReport { + file: file_display, + total: 0, + functions: 0, + structs: 0, + enums: 0, + traits: 0, + consts: 0, + statics: 0, + symbols: Vec::new(), + }; + }; + + let mut rows = Vec::new(); + for function in &file_symbols.functions { + rows.push(FileSymbolRow { + kind: FileSymbolKind::Fn, + symbol: function.name.clone(), + line_start: Some(function.line_start), + line_end: Some(function.line_end), + }); + } + for item in &file_symbols.structs { + rows.push(FileSymbolRow { + kind: FileSymbolKind::Struct, + symbol: item.name.clone(), + line_start: None, + line_end: None, + }); + } + for item in &file_symbols.enums { + rows.push(FileSymbolRow { + kind: FileSymbolKind::Enum, + symbol: item.name.clone(), + line_start: None, + line_end: None, + }); + } + for item in &file_symbols.traits { + rows.push(FileSymbolRow { + kind: FileSymbolKind::Trait, + symbol: item.name.clone(), + line_start: None, + line_end: None, + }); + } + for item in &file_symbols.consts { + rows.push(FileSymbolRow { + kind: FileSymbolKind::Const, + symbol: item.name.clone(), + line_start: None, + line_end: None, + }); + } + for item in &file_symbols.statics { + rows.push(FileSymbolRow { + kind: FileSymbolKind::Static, + symbol: item.name.clone(), + line_start: None, + line_end: None, + }); + } + + let functions = file_symbols.functions.len(); + let structs = file_symbols.structs.len(); + let enums = file_symbols.enums.len(); + let traits = file_symbols.traits.len(); + let consts = file_symbols.consts.len(); + let statics = file_symbols.statics.len(); + let total = functions + structs + enums + traits + consts + statics; + + FileSymbolReport { + file: file_display, + total, + functions, + structs, + enums, + traits, + consts, + statics, + symbols: rows, + } +} + pub fn render_symbol_report(report: &SymbolReport, verbose: bool) { render::section_header("Symbols"); @@ -153,6 +276,86 @@ pub fn render_symbols_json(rs_files: &[PathBuf], project_path: &Path) { println!("{}", serde_json::to_string(&report).unwrap()); } +pub fn render_symbols_file(rs_files: &[PathBuf], project_path: &Path, file: &Path, verbose: bool) { + let symbols = ast_parser::parse_project(rs_files); + let report = analyze_symbols_file(&symbols, project_path, file); + render_file_symbol_report(&report, verbose); +} + +pub fn render_symbols_file_json(rs_files: &[PathBuf], project_path: &Path, file: &Path) { + let symbols = ast_parser::parse_project(rs_files); + let report = analyze_symbols_file(&symbols, project_path, file); + println!("{}", serde_json::to_string(&report).unwrap()); +} + +pub fn render_file_symbol_report(report: &FileSymbolReport, verbose: bool) { + render::section_header("Symbols"); + + if verbose { + render::verbose_block(&[ + "Counts are Rust AST items found in the selected file.", + "Functions includes free functions and impl methods.", + "Line spans are available for functions; other symbol kinds show lines -.", + ]); + } + + println!( + " {} {} {} {} {} {} {} {} {} {} {} {} {} {}", + "total".cyan(), + report.total.to_string().bold(), + "fn".cyan(), + report.functions.to_string().bold(), + "struct".cyan(), + report.structs.to_string().bold(), + "enum".cyan(), + report.enums.to_string().bold(), + "trait".cyan(), + report.traits.to_string().bold(), + "const".cyan(), + report.consts.to_string().bold(), + "static".cyan(), + report.statics.to_string().bold(), + ); + + println!(); + println!( + " {:<10} {:<48} {}", + "kind".bold(), + "symbol".bold(), + "lines".bold() + ); + for row in &report.symbols { + let lines = match (row.line_start, row.line_end) { + (Some(start), Some(end)) => format!("{start}-{end}"), + _ => "-".to_string(), + }; + println!( + " {:<10} {:<48} {}", + file_symbol_kind_label(&row.kind), + truncate(&row.symbol, 48), + lines, + ); + } +} + +fn file_symbol_kind_label(kind: &FileSymbolKind) -> &'static str { + match kind { + FileSymbolKind::Fn => "fn", + FileSymbolKind::Struct => "struct", + FileSymbolKind::Enum => "enum", + FileSymbolKind::Trait => "trait", + FileSymbolKind::Const => "const", + FileSymbolKind::Static => "static", + } +} + +fn relative_path_string(project_path: &Path, path: &Path) -> String { + path.strip_prefix(project_path) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + fn strip_prefix<'a>(path: &'a Path, base: &Path) -> &'a Path { path.strip_prefix(base).unwrap_or(path) } diff --git a/tests/cluster_cli.rs b/tests/cluster_cli.rs new file mode 100644 index 0000000..0f7c01a --- /dev/null +++ b/tests/cluster_cli.rs @@ -0,0 +1,166 @@ +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temp_project(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("cstat-cluster-{name}-{unique}")); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + fs::write( + root.join("src/lib.rs"), + r#" +struct Config; +struct Worker; + +impl Config { + fn new(_args: Vec) -> Self { Self } +} + +impl Worker { + fn new(_config: &Config) -> Self { Self } + fn wait(&self, _config: &Config) -> bool { true } +} + +fn serve(_worker: Worker, ready: bool) -> usize { + if ready { 1 } else { 0 } +} + +fn cleanup(value: usize) -> usize { value } + +fn run(args: Vec) -> usize { + let config = Config::new(args); + let worker = Worker::new(&config); + let ready = worker.wait(&config); + let result = serve(worker, ready); + let mut total = 0usize; + total = cleanup(result); + total += 1; + total +} +"#, + ) + .unwrap(); + root +} + +fn run_cstat(path: &Path, args: &[&str]) -> Output { + let bin = env!("CARGO_BIN_EXE_cstat"); + let mut command = Command::new(bin); + command.args(["--no-color", "--path"]); + command.arg(path); + command.args(args); + command.output().expect("invoke cstat binary") +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "cstat failed: status={:?}\nstderr={}\nstdout={}", + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); +} + +#[test] +fn cluster_human_verbose_explains_value_clusters() { + let root = temp_project("human"); + let file = root.join("src/lib.rs"); + let output = run_cstat(&file, &["cluster", "-v"]); + assert_success(&output); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Value clusters"), "stdout={stdout}"); + assert!( + stdout.contains( + "value = a function parameter or local let-binding collected from the Rust AST." + ), + "missing value explanation: {stdout}" + ); + assert!(stdout.contains("references"), "stdout={stdout}"); + assert!(stdout.contains("mutations"), "stdout={stdout}"); + assert!(stdout.contains("connected values"), "stdout={stdout}"); + assert!(stdout.contains("fn run:"), "stdout={stdout}"); + assert!(stdout.contains("config"), "stdout={stdout}"); + assert!(stdout.contains("worker"), "stdout={stdout}"); + assert!(stdout.contains("total"), "stdout={stdout}"); + assert!( + !stdout.contains("def live uses nbrs pressure"), + "old header still present: {stdout}" + ); + assert!( + !stdout.contains("pressure"), + "pressure still present: {stdout}" + ); + assert!( + !stdout.contains("Top signal"), + "old signal text still present: {stdout}" + ); + assert!( + !stdout.contains("Use lines"), + "old use-line text still present: {stdout}" + ); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn cluster_json_reports_def_use_clusters() { + let root = temp_project("json"); + let file = root.join("src/lib.rs"); + let output = run_cstat(&file, &["--json", "cluster"]); + assert_success(&output); + + let json: Value = serde_json::from_slice(&output.stdout).expect("cluster json"); + let functions = json + .get("functions") + .and_then(Value::as_array) + .expect("functions array"); + let run = functions + .iter() + .find(|function| function.get("name").and_then(Value::as_str) == Some("run")) + .expect("run function"); + let clusters = run + .get("clusters") + .and_then(Value::as_array) + .expect("clusters array"); + let names = clusters + .iter() + .filter_map(|cluster| cluster.get("name").and_then(Value::as_str)) + .collect::>(); + assert!(names.contains(&"config"), "names={names:?}"); + assert!(names.contains(&"worker"), "names={names:?}"); + let total = clusters + .iter() + .find(|cluster| cluster.get("name").and_then(Value::as_str) == Some("total")) + .expect("total cluster"); + let mutation_lines = total + .get("mutation_lines") + .and_then(Value::as_array) + .expect("mutation_lines array"); + assert_eq!(mutation_lines.len(), 2, "total={total}"); + for removed_key in [ + "pressure", + "defined_line", + "live_start", + "live_end", + "live_span", + ] { + assert!( + total.get(removed_key).is_none(), + "{removed_key} should not be present in total={total}" + ); + } + + fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/coverage_cli.rs b/tests/coverage_cli.rs new file mode 100644 index 0000000..12a26d2 --- /dev/null +++ b/tests/coverage_cli.rs @@ -0,0 +1,139 @@ +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temp_project(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("cstat-coverage-cli-{name}-{unique}")); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + fs::write( + root.join("src/lib.rs"), + r#"pub fn target(flag: bool) -> u8 { + if flag { + 1 + } else { + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn covers_true_arm() { + assert_eq!(target(true), 1); + } +} +"#, + ) + .unwrap(); + root +} + +fn export_json(root: &Path) -> PathBuf { + let source = root.join("src/lib.rs"); + let escaped_source = source.to_string_lossy().replace('\\', "\\\\"); + let content = format!( + r#"{{"type":"llvm.coverage.json.export","data":[{{"files":[{{"filename":"{}","segments":[[1,1,1,true,true,false],[2,5,1,true,true,false],[3,9,1,true,true,false],[5,9,0,true,true,false]],"branches":[[2,8,2,12,1,0,0,0,0]],"summary":{{}}}}]}}]}}"#, + escaped_source + ); + let export = root.join("coverage.json"); + fs::write(&export, content).unwrap(); + export +} + +fn run_cstat(path: &Path, export: &Path, args: &[&str]) -> Output { + let bin = env!("CARGO_BIN_EXE_cstat"); + let mut command = Command::new(bin); + command.args(["--no-color", "--path"]); + command.arg(path); + command.args(args); + command.env("CSTAT_LLVM_COV_EXPORT_JSON", export); + command.output().expect("invoke cstat binary") +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "cstat failed: status={:?}\nstderr={}\nstdout={}", + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); +} + +#[test] +fn coverage_no_run_reports_dynamic_lines_and_branches_from_export_json() { + let root = temp_project("json"); + let file = root.join("src/lib.rs"); + let export = export_json(&root); + let output = run_cstat(&file, &export, &["--json", "coverage", "--no-run"]); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let value: Value = serde_json::from_str(&stdout).expect("parse coverage JSON"); + assert_eq!(value["target"], "src/lib.rs"); + assert!(value.get("static_reachability").is_some(), "json={stdout}"); + assert!( + value.get("projected_line_reachability").is_some(), + "json={stdout}" + ); + assert_eq!(value["dynamic_line_coverage"]["coverable_lines"], 4); + assert_eq!(value["dynamic_line_coverage"]["executed_lines"], 3); + assert_eq!( + value["dynamic_line_coverage"]["missing_line_numbers"], + serde_json::json!([5]) + ); + assert_eq!(value["branch_coverage"]["total_branch_outcomes"], 2); + assert_eq!(value["branch_coverage"]["covered_branch_outcomes"], 1); + assert_eq!(value["branch_coverage"]["missing_branch_outcomes"], 1); + assert_eq!( + value["branch_coverage"]["sites"][0]["taken"], + serde_json::json!(["true"]) + ); + assert_eq!( + value["branch_coverage"]["sites"][0]["missing"], + serde_json::json!(["false"]) + ); + assert_eq!(value["tool_status"]["ran_tests"], false); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn coverage_human_separates_static_projected_dynamic_and_branch_sections() { + let root = temp_project("human"); + let file = root.join("src/lib.rs"); + let export = export_json(&root); + let output = run_cstat(&file, &export, &["coverage", "--no-run"]); + assert_success(&output); + + let stdout = String::from_utf8_lossy(&output.stdout); + for section in [ + "Static test/benchmark reachability", + "Projected line reachability", + "Dynamic line coverage", + "Branch coverage", + ] { + assert!(stdout.contains(section), "missing {section}: {stdout}"); + } + assert!(stdout.contains("missing lines: 5"), "stdout={stdout}"); + assert!( + stdout.contains("line 2: flag missing false"), + "stdout={stdout}" + ); + assert!(!stdout.contains("Path coverage"), "stdout={stdout}"); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/file_summary_cli.rs b/tests/file_summary_cli.rs new file mode 100644 index 0000000..53a4ecd --- /dev/null +++ b/tests/file_summary_cli.rs @@ -0,0 +1,326 @@ +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temp_project(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("cstat-file-summary-{name}-{unique}")); + fs::create_dir_all(root.join("src")).unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + fs::write( + root.join("src/lib.rs"), + r#" +mod other; + +use crate::other::external_helper; +use std::fmt::Debug; + +const LIMIT: usize = 10; +static NAME: &str = "fixture"; + +struct Parser; +enum Mode { + Fast, + Slow, +} + +trait Parse { + fn parse(&self); +} + +fn live() { + helper(); + external_helper(); +} + +fn helper() {} + +fn orphan() {} + +fn dead_parent() { + dead_child(); +} + +fn dead_child() {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn covers_live() { + live(); + } +} +"#, + ) + .unwrap(); + fs::write( + root.join("src/other.rs"), + r#" +pub fn external_helper() {} +pub fn other_dead() {} +"#, + ) + .unwrap(); + root +} + +fn run_cstat(path: &Path, args: &[&str]) -> Output { + let bin = env!("CARGO_BIN_EXE_cstat"); + let mut command = Command::new(bin); + command.args(["--no-color", "--path"]); + command.arg(path); + command.args(args); + command.output().expect("invoke cstat binary") +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "cstat failed: status={:?}\nstderr={}\nstdout={}", + output.status, + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout), + ); +} + +fn assert_json_array_contains(array: &Value, expected: &str) { + let values = array.as_array().expect("json array"); + assert!( + values.iter().any(|value| value == expected), + "expected {expected:?} in {values:?}", + ); +} + +#[test] +fn file_summary_human_accepts_file_path() { + let root = temp_project("human"); + let file = root.join("src/lib.rs"); + let output = run_cstat(&file, &["-v", "summary"]); + assert_success(&output); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("cstat file summary (src/lib.rs)"), + "stdout={stdout}" + ); + for section in [ + "Projected line reachability", + "Static test/benchmark reachability", + "Symbols", + "Dependencies", + "Dead code candidates", + "Call trace size", + ] { + assert!( + stdout.contains(section), + "missing section {section}: {stdout}" + ); + } + for full_section in [ + "ℹ Full section: cstat loc --path src/lib.rs -v", + "ℹ Full section: cstat test-reachability --path src/lib.rs -v", + "ℹ Full section: cstat symbols --path src/lib.rs -v", + "ℹ Full section: cstat deps --path src/lib.rs -v", + "ℹ Full section: cstat dead-code --path src/lib.rs -v", + "ℹ Full section: cstat call-trace --path src/lib.rs -v", + ] { + assert!( + stdout.contains(full_section), + "missing verbose line {full_section}: {stdout}", + ); + } + for expected in [ + "fn 6", "struct 1", "enum 1", "trait 1", "const 1", "static 1", + ] { + assert!(stdout.contains(expected), "missing {expected}: {stdout}"); + } + assert!(stdout.contains("test/bench roots 1"), "stdout={stdout}"); + assert!( + stdout.contains("not statically reachable:"), + "stdout={stdout}" + ); + assert!(stdout.contains("orphan"), "stdout={stdout}"); + assert!(stdout.contains("dead_child"), "stdout={stdout}"); + assert!(stdout.contains("in-file fan-in"), "stdout={stdout}"); + for expected in ["Call trace size", "live", "dead_parent", "orphan"] { + assert!(stdout.contains(expected), "missing {expected}: {stdout}"); + } + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn file_summary_json_is_untruncated_and_file_scoped() { + let root = temp_project("json"); + let file = root.join("src/lib.rs"); + let output = run_cstat(&file, &["--json", "summary"]); + assert_success(&output); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let value: Value = serde_json::from_str(&stdout).expect("parse file summary JSON"); + assert_eq!(value["file"], "src/lib.rs"); + assert_eq!(value["project"], "fixture"); + for field in [ + "line_counts", + "static_reachability", + "symbols", + "dependencies", + "dead_code", + "call_trace", + ] { + assert!(value.get(field).is_some(), "missing {field}: {stdout}"); + } + assert_eq!(value["symbols"]["functions"], 6); + assert_eq!(value["symbols"]["structs"], 1); + assert_eq!(value["symbols"]["enums"], 1); + assert_eq!(value["symbols"]["traits"], 1); + assert_eq!(value["symbols"]["consts"], 1); + assert_eq!(value["symbols"]["statics"], 1); + assert_eq!( + value["static_reachability"]["test_benchmark_entry_count"], + 1 + ); + assert_json_array_contains(&value["static_reachability"]["reachable_functions"], "live"); + assert_json_array_contains( + &value["static_reachability"]["reachable_functions"], + "helper", + ); + for function in ["orphan", "dead_parent", "dead_child"] { + assert_json_array_contains( + &value["static_reachability"]["unreachable_functions"], + function, + ); + } + + let dead_code = value["dead_code"]["functions"] + .as_array() + .expect("dead code functions"); + let dead_child = dead_code + .iter() + .find(|entry| entry["function"] == "dead_child") + .expect("dead_child dead-code row"); + assert_eq!(dead_child["candidate"], true); + assert_eq!(dead_child["in_file_fan_in"], 1); + assert!( + dead_child["reason"] + .as_str() + .unwrap() + .contains("no test/benchmark root reaches that caller"), + "dead_child reason={:?}", + dead_child["reason"] + ); + let orphan = dead_code + .iter() + .find(|entry| entry["function"] == "orphan") + .expect("orphan dead-code row"); + assert!( + orphan["reason"] + .as_str() + .unwrap() + .contains("no direct, trait, drop, function-item, or closure edge"), + "orphan reason={:?}", + orphan["reason"] + ); + + let traces = value["call_trace"]["functions"] + .as_array() + .expect("call trace functions"); + let live = traces + .iter() + .find(|entry| entry["function"] == "live") + .expect("live trace row"); + assert!( + live["functions_reached"].as_u64().unwrap() > 1, + "live trace={live:?}", + ); + let orphan = traces + .iter() + .find(|entry| entry["function"] == "orphan") + .expect("orphan trace row"); + assert_eq!(orphan["functions_reached"], 1); + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn file_section_commands_accept_file_path() { + let root = temp_project("sections"); + let file = root.join("src/lib.rs"); + for command in [ + "loc", + "symbols", + "deps", + "dead-code", + "test-reachability", + "call-trace", + ] { + let output = run_cstat(&file, &["-v", command]); + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(!stdout.is_empty(), "empty output for {command}"); + if command == "symbols" { + for expected in [ + "fn 6", "struct 1", "enum 1", "trait 1", "const 1", "static 1", + ] { + assert!(stdout.contains(expected), "missing {expected}: {stdout}"); + } + } + if command == "call-trace" { + for expected in ["live", "helper", "other::external_helper"] { + assert!(stdout.contains(expected), "missing {expected}: {stdout}"); + } + } + } + + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn file_path_errors_are_clear() { + let root = temp_project("errors"); + let notes = root.join("notes.rs"); + fs::write(¬es, "fn scratch() {}\n").unwrap(); + + let output = run_cstat(¬es, &["summary"]); + assert!( + !output.status.success(), + "unexpected success: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("file is not under this crate's src, tests, or benches"), + "stderr={stderr}", + ); + + let output = run_cstat(¬es, &["--json", "summary"]); + assert!( + !output.status.success(), + "unexpected json success: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + let value: Value = serde_json::from_str(&stdout).expect("parse error JSON"); + assert!( + value["error"] + .as_str() + .unwrap() + .contains("file is not under this crate's src, tests, or benches"), + "json={value}", + ); + + fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/test_reachability_cli.rs b/tests/test_reachability_cli.rs index efea84b..606dd16 100644 --- a/tests/test_reachability_cli.rs +++ b/tests/test_reachability_cli.rs @@ -60,6 +60,30 @@ fn assert_success(output: &Output) { ); } +fn json_array_contains_suffix(array: &Value, suffix: &str) -> bool { + array + .as_array() + .expect("json array") + .iter() + .filter_map(Value::as_str) + .any(|value| value.ends_with(suffix)) +} + +fn json_edges_contain_suffix(array: &Value, caller_suffix: &str, callee_suffix: &str) -> bool { + array + .as_array() + .expect("json edge array") + .iter() + .any(|edge| { + edge["caller"] + .as_str() + .is_some_and(|caller| caller.ends_with(caller_suffix)) + && edge["callee"] + .as_str() + .is_some_and(|callee| callee.ends_with(callee_suffix)) + }) +} + #[test] fn test_reachability_human_output_uses_static_reachability_terms() { let root = temp_project("human"); @@ -101,6 +125,7 @@ fn test_reachability_human_output_uses_static_reachability_terms() { fn test_reachability_json_uses_reachability_fields() { let root = temp_project("json"); let output = run_cstat(&root, &["--json", "test-reachability"]); + assert_success(&output); let stdout = String::from_utf8(output.stdout).unwrap(); @@ -271,3 +296,271 @@ fn help_lists_test_reachability_and_advanced_flow_heatmap() { fs::remove_dir_all(root).unwrap(); } + +#[test] +fn selected_file_reachability_covers_mvp_chat_false_negative_patterns() { + let root = temp_project("mvp-chat-patterns"); + let bin_dir = root.join("src/bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let file = bin_dir.join("mvp_chat.rs"); + fs::write( + &file, + r#" +struct RuntimeConfigProfile; +struct RelayMode; +struct Config { + profile: RuntimeConfigProfile, + relay_mode: RelayMode, + model_path: Option<&'static str>, +} + +impl RuntimeConfigProfile { + fn as_str(&self) -> &'static str { "docker" } + fn default_provider(&self) -> &'static str { "vastai" } +} + +impl RelayMode { + fn as_str(&self) -> &'static str { "auto" } +} + +fn relay_mode_env_value(_mode: &str) -> &'static str { "1" } +fn default_cached_model_path() -> &'static str { "/tmp/model" } +fn run_status() -> bool { true } +fn request_stop() {} + +impl Config { + fn orchestrator_cli_args(&self) { + let mut args = Vec::new(); + args.extend([ + "--relay-mode".to_owned(), + relay_mode_env_value(self.relay_mode.as_str()).to_owned(), + ]); + self.profile.as_str(); + self.model_path.unwrap_or_else(default_cached_model_path); + } +} + +struct ParsedArgs { provider: Option<&'static str> } +impl ParsedArgs { + fn apply_profile(&mut self, config_profile: RuntimeConfigProfile) { + self.set_provider_selector(config_profile.default_provider()); + } + fn set_provider_selector(&mut self, provider: &'static str) { + self.provider = Some(provider); + } +} + +trait VastAiApproval { + fn ask(&mut self) -> bool; +} + +struct StdinVastAiApproval; + +impl VastAiApproval for StdinVastAiApproval { + fn ask(&mut self) -> bool { true } +} + +fn confirm_vastai_approval(approval: &mut P) -> bool { + approval.ask() +} + +struct OfferPreviewer; +struct Offer { + previewer: OfferPreviewer, +} + +impl OfferPreviewer { + fn preview(&self) {} +} + +impl Offer { + fn show(&self) { + self.previewer.preview(); + } +} + +struct Command; +struct OrchChild(Command); + +impl OrchChild { + fn shutdown(&mut self, _wait: bool) {} +} + +impl Drop for OrchChild { + fn drop(&mut self) { + self.shutdown(false); + } +} + +fn consume_child(_child: OrchChild) {} + +fn ensure_orch_binary() -> bool { + ensure_orch_binary_with("config", "root", "default-orch", run_status) +} + +fn ensure_orch_binary_with( + _config: &str, + _root: &str, + _default_orch: &str, + run_status_fn: fn() -> bool, +) -> bool { + run_status_fn() +} + +fn install_signal_handler() { + unsafe { + libc::signal(15, request_stop as *const () as usize); + } +} + +struct MutRunner; +impl MutRunner { + fn run(&mut self, value: &'static str) { + nested_method_leaf(value); + } +} +fn nested_arg() -> &'static str { + nested_free_leaf(); + "x" +} +fn nested_free_leaf() {} +fn nested_method_leaf(_value: &'static str) {} + +fn main() {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn covers_selected_file_patterns() { + let config = Config { + profile: RuntimeConfigProfile, + relay_mode: RelayMode, + model_path: None, + }; + config.orchestrator_cli_args(); + let mut parsed = ParsedArgs { provider: None }; + parsed.apply_profile(RuntimeConfigProfile); + ensure_orch_binary(); + let mut runner = MutRunner; + runner.run(nested_arg()); + let mut approval = StdinVastAiApproval; + confirm_vastai_approval(&mut approval); + let offer = Offer { previewer: OfferPreviewer }; + offer.show(); + let child = OrchChild(Command); + consume_child(child); + install_signal_handler(); + } +} +"#, + ) + .unwrap(); + + let output = run_cstat(&file, &["--json", "test-reachability"]); + assert_success(&output); + let stdout = String::from_utf8(output.stdout).unwrap(); + let value: Value = serde_json::from_str(&stdout).expect("parse selected reachability JSON"); + assert_eq!(value["file"], "src/bin/mvp_chat.rs"); + assert!( + json_array_contains_suffix(&value["wrapper_entrypoints"], "main"), + "wrapper_entrypoints={:?}", + value["wrapper_entrypoints"] + ); + assert!( + !json_array_contains_suffix(&value["unreachable_functions"], "main"), + "main should be classified as wrapper, not uncovered production: {stdout}" + ); + + let expected_suffixes = [ + "ParsedArgs::apply_profile", + "ParsedArgs::set_provider_selector", + "RuntimeConfigProfile::as_str", + "RuntimeConfigProfile::default_provider", + "relay_mode_env_value", + "default_cached_model_path", + "ensure_orch_binary", + "ensure_orch_binary_with", + "run_status", + "request_stop", + "StdinVastAiApproval::ask", + "OfferPreviewer::preview", + "MutRunner::run", + "nested_arg", + "nested_free_leaf", + "nested_method_leaf", + "OrchChild::drop", + "OrchChild::shutdown", + ]; + + for suffix in expected_suffixes { + assert!( + json_array_contains_suffix(&value["reachable_functions"], suffix), + "reachable_functions missing {suffix}: {stdout}" + ); + assert!( + !json_array_contains_suffix(&value["unreachable_functions"], suffix), + "unreachable_functions still contains {suffix}: {stdout}" + ); + } + assert_eq!( + value["unreachable_function_count"], 0, + "selected-file production functions should all be reachable: {stdout}" + ); + + for (caller, callee) in [ + ( + "Config::orchestrator_cli_args", + "RuntimeConfigProfile::as_str", + ), + ("Config::orchestrator_cli_args", "RelayMode::as_str"), + ("Config::orchestrator_cli_args", "relay_mode_env_value"), + ("Config::orchestrator_cli_args", "default_cached_model_path"), + ( + "ParsedArgs::apply_profile", + "ParsedArgs::set_provider_selector", + ), + ( + "ParsedArgs::apply_profile", + "RuntimeConfigProfile::default_provider", + ), + ("ensure_orch_binary", "ensure_orch_binary_with"), + ("ensure_orch_binary_with", "run_status"), + ("install_signal_handler", "request_stop"), + ("confirm_vastai_approval", "StdinVastAiApproval::ask"), + ("Offer::show", "OfferPreviewer::preview"), + ("MutRunner::run", "nested_method_leaf"), + ("tests::covers_selected_file_patterns", "nested_arg"), + ("nested_arg", "nested_free_leaf"), + ("OrchChild::drop", "OrchChild::shutdown"), + ] { + assert!( + json_edges_contain_suffix(&value["reachable_edges"], caller, callee), + "reachable_edges missing {caller} -> {callee}: {stdout}" + ); + } + + let dead_output = run_cstat(&file, &["--json", "dead-code"]); + assert_success(&dead_output); + let dead_stdout = String::from_utf8(dead_output.stdout).unwrap(); + let dead_value: Value = serde_json::from_str(&dead_stdout).expect("parse dead-code JSON"); + let dead_functions = dead_value["functions"] + .as_array() + .expect("dead-code functions"); + for suffix in expected_suffixes { + let row = dead_functions + .iter() + .find(|entry| { + entry["function"] + .as_str() + .is_some_and(|function| function.ends_with(suffix)) + }) + .unwrap_or_else(|| panic!("dead-code row missing {suffix}: {dead_stdout}")); + assert_eq!( + row["candidate"], false, + "dead-code still marks {suffix} as candidate: {dead_stdout}" + ); + } + fs::remove_dir_all(root).unwrap(); +}