stash current state
This commit is contained in:
parent
0414610d60
commit
d699420eb1
20 changed files with 7306 additions and 182 deletions
|
|
@ -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<EnumInfo>,
|
||||
pub traits: Vec<TraitInfo>,
|
||||
pub impls: Vec<ImplInfo>,
|
||||
pub consts: Vec<ConstInfo>,
|
||||
pub statics: Vec<StaticInfo>,
|
||||
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<EnumInfo>,
|
||||
traits: Vec<TraitInfo>,
|
||||
impls: Vec<ImplInfo>,
|
||||
consts: Vec<ConstInfo>,
|
||||
statics: Vec<StaticInfo>,
|
||||
module_parts: Vec<String>,
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<TraceNode>,
|
||||
pub struct TraceNode {
|
||||
pub function: String,
|
||||
pub cycle: bool,
|
||||
pub children: Vec<TraceNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FunctionTraceSummary {
|
||||
pub function: String,
|
||||
pub functions_reached: usize,
|
||||
pub max_depth: usize,
|
||||
pub direct_callees: Vec<String>,
|
||||
pub tree: TraceNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FileCallTraceReport {
|
||||
pub file: String,
|
||||
pub functions: Vec<FunctionTraceSummary>,
|
||||
}
|
||||
|
||||
#[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<FunctionTraceSummary> = 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<FunctionTraceSummary, String> {
|
||||
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!(" <none>");
|
||||
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, "");
|
||||
|
|
|
|||
631
src/cluster.rs
Normal file
631
src/cluster.rs
Normal file
|
|
@ -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<FileClusterReport>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FileClusterReport {
|
||||
pub path: String,
|
||||
pub functions: Vec<FunctionClusterReport>,
|
||||
}
|
||||
|
||||
#[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<ValueCluster>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ValueCluster {
|
||||
pub name: String,
|
||||
pub reference_lines: Vec<usize>,
|
||||
pub mutation_lines: Vec<usize>,
|
||||
pub connected_values: Vec<String>,
|
||||
#[serde(skip)]
|
||||
sort_line: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StatementAccess {
|
||||
line_start: usize,
|
||||
defines: Vec<String>,
|
||||
references: BTreeSet<String>,
|
||||
mutations: BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WorkingCluster {
|
||||
name: String,
|
||||
defined_line: usize,
|
||||
reference_lines: BTreeSet<usize>,
|
||||
mutation_lines: BTreeSet<usize>,
|
||||
connected_values: BTreeSet<String>,
|
||||
}
|
||||
|
||||
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<FileClusterReport> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<String>,
|
||||
is_test: bool,
|
||||
}
|
||||
|
||||
fn collect_item_functions<'a>(
|
||||
items: &'a [Item],
|
||||
module_parts: &mut Vec<String>,
|
||||
out: &mut Vec<FunctionCandidate<'a>>,
|
||||
) {
|
||||
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<FunctionClusterReport> {
|
||||
let mut known_values = BTreeSet::new();
|
||||
let mut clusters: BTreeMap<String, WorkingCluster> = 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::<Vec<_>>();
|
||||
|
||||
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<FnArg, syn::token::Comma>,
|
||||
) -> Vec<String> {
|
||||
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<String> {
|
||||
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<String>) -> BTreeSet<String> {
|
||||
let mut collector = ReferenceCollector {
|
||||
known_values,
|
||||
references: BTreeSet::new(),
|
||||
};
|
||||
collector.visit_stmt(stmt);
|
||||
collector.references
|
||||
}
|
||||
|
||||
fn collect_stmt_mutations(stmt: &Stmt, known_values: &BTreeSet<String>) -> BTreeSet<String> {
|
||||
let mut collector = MutationCollector {
|
||||
known_values,
|
||||
mutations: BTreeSet::new(),
|
||||
};
|
||||
collector.visit_stmt(stmt);
|
||||
collector.mutations
|
||||
}
|
||||
|
||||
struct ReferenceCollector<'a> {
|
||||
known_values: &'a BTreeSet<String>,
|
||||
references: BTreeSet<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
mutations: BTreeSet<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String>) {
|
||||
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::<Vec<_>>()
|
||||
.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()
|
||||
}
|
||||
1297
src/coverage.rs
Normal file
1297
src/coverage.rs
Normal file
File diff suppressed because it is too large
Load diff
315
src/datapaths.rs
315
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<FileStaticReachabilityEntry>,
|
||||
pub reachable_functions: Vec<String>,
|
||||
pub unreachable_functions: Vec<String>,
|
||||
pub reachable_edges: Vec<FileStaticReachabilityEdge>,
|
||||
pub wrapper_entrypoints: Vec<String>,
|
||||
}
|
||||
|
||||
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<String> = file_functions
|
||||
.functions
|
||||
.iter()
|
||||
.map(|function| function.graph_name.clone())
|
||||
.collect();
|
||||
let production: HashSet<String> = 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<String> = 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<String> = 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<String> = reachable_functions_set.into_iter().collect();
|
||||
reachable_functions.sort();
|
||||
let mut unreachable_functions: Vec<String> = 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<FileStaticReachabilityEdge> = 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!(" {}", "<none>".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!(" {}", "<none>".dimmed());
|
||||
} else {
|
||||
for function in &report.unreachable_functions {
|
||||
println!(" {} {}", "•".yellow(), function);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!(" {}", "reachable local edges:".bold());
|
||||
if report.reachable_edges.is_empty() {
|
||||
println!(" {}", "<none>".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<String, usize
|
|||
println!();
|
||||
}
|
||||
|
||||
/// Compact summary data for embedding in the dashboard.
|
||||
pub struct DatapathSummary {
|
||||
/// Percentage of functions reachable from entry points.
|
||||
pub coverage_pct: f64,
|
||||
/// Compact static entrypoint-reachability data for embedding in the dashboard.
|
||||
pub struct EntrypointReachabilitySummary {
|
||||
/// Percentage of functions statically reachable from entry points.
|
||||
pub reachable_pct: f64,
|
||||
/// Total function count.
|
||||
pub total_functions: usize,
|
||||
/// Number of reachable functions.
|
||||
|
|
@ -703,8 +995,11 @@ pub struct DatapathSummary {
|
|||
pub cold_count: usize,
|
||||
}
|
||||
|
||||
/// Compute compact datapath coverage for dashboard use.
|
||||
pub fn datapath_summary(rs_files: &[PathBuf], project_path: &Path) -> Option<DatapathSummary> {
|
||||
/// Compute compact static entrypoint reachability for dashboard use.
|
||||
pub fn entrypoint_reachability_summary(
|
||||
rs_files: &[PathBuf],
|
||||
project_path: &Path,
|
||||
) -> Option<EntrypointReachabilitySummary> {
|
||||
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<Dat
|
|||
let reachable = reachable_from_entries(&graph);
|
||||
let reachable_count = reachable.len();
|
||||
let cold_count = total_functions.saturating_sub(reachable_count);
|
||||
let coverage_pct = reachable_count as f64 / total_functions.max(1) as f64 * 100.0;
|
||||
let reachable_pct = reachable_count as f64 / total_functions.max(1) as f64 * 100.0;
|
||||
|
||||
Some(DatapathSummary {
|
||||
coverage_pct,
|
||||
Some(EntrypointReachabilitySummary {
|
||||
reachable_pct,
|
||||
total_functions,
|
||||
reachable_count,
|
||||
cold_count,
|
||||
|
|
|
|||
194
src/dead_code.rs
194
src/dead_code.rs
|
|
@ -1,9 +1,10 @@
|
|||
use colored::Colorize;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::flow::{self, CallGraph};
|
||||
use crate::render;
|
||||
use crate::{ast_parser, file_metrics, render};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeadCodeCandidate {
|
||||
|
|
@ -21,6 +22,26 @@ pub struct DeadCodeReport {
|
|||
pub analysis_note: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FileDeadCodeFunction {
|
||||
pub function: String,
|
||||
pub reached_from_crate_roots: bool,
|
||||
pub crate_fan_in: usize,
|
||||
pub in_file_fan_in: usize,
|
||||
pub candidate: bool,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FileDeadCodeReport {
|
||||
pub file: String,
|
||||
pub crate_roots: Vec<String>,
|
||||
pub file_function_count: usize,
|
||||
pub reached_from_crate_count: usize,
|
||||
pub candidate_count: usize,
|
||||
pub functions: Vec<FileDeadCodeFunction>,
|
||||
}
|
||||
|
||||
pub fn analyze_graph(graph: &CallGraph) -> DeadCodeReport {
|
||||
let reachable = flow::reachable_from_entries(graph);
|
||||
let mut candidates: Vec<DeadCodeCandidate> = 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<String> = 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!(" {}", "<none>".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('\\', "/")
|
||||
}
|
||||
|
|
|
|||
137
src/deps/mod.rs
137
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<String>,
|
||||
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<FileDependency>,
|
||||
pub external: Vec<FileDependency>,
|
||||
}
|
||||
|
||||
/// Extract use targets from a Rust source file.
|
||||
fn extract_use_targets(source: &str) -> Vec<String> {
|
||||
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<String> = 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<String> = 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!(" {}", "<none>".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!(" {}", "<none>".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<String, Vec<String>>,
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>,
|
||||
pub target_rs_files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
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<AnalysisTarget, String> {
|
||||
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:
|
||||
|
|
|
|||
179
src/file_metrics.rs
Normal file
179
src/file_metrics.rs
Normal file
|
|
@ -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<SourceSpanFilter>,
|
||||
}
|
||||
|
||||
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<FileFunction>,
|
||||
#[allow(dead_code)]
|
||||
pub graph_name_to_index: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
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<FileFunction> = 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,
|
||||
}
|
||||
}
|
||||
495
src/file_summary.rs
Normal file
495
src/file_summary.rs
Normal file
|
|
@ -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<String> = 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!(" {}", "<none>".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!(" {}", "<none>".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!(" {}", "<none>".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
|
||||
}
|
||||
}
|
||||
2324
src/flow.rs
2324
src/flow.rs
File diff suppressed because it is too large
Load diff
223
src/loc.rs
223
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<DirBreakdownJson>,
|
||||
}
|
||||
|
||||
#[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<FileLineSpan>,
|
||||
pub unreachable_spans: Vec<FileLineSpan>,
|
||||
}
|
||||
|
||||
/// 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<FileLocStats> {
|
||||
pub fn count_file_lines(path: &Path) -> Option<FileLocStats> {
|
||||
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<FileLocStats> {
|
|||
})
|
||||
}
|
||||
|
||||
fn count_lines(path: &Path) -> Option<FileLocStats> {
|
||||
count_file_lines(path)
|
||||
}
|
||||
|
||||
fn code_lines_in_spans(path: &Path, spans: &[(usize, usize)]) -> HashSet<usize> {
|
||||
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<String>,
|
||||
file_display_path: &str,
|
||||
) -> Option<FileProjectedLineReachabilityReport> {
|
||||
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<FileLocStats>, Option<AggregateStats>) {
|
||||
let mut file_stats: Vec<FileLocStats> = 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<FileProjectedLineReachabilityReport> {
|
||||
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<String> = 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<FileProjectedLineReachabilityReport>,
|
||||
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!(" {}", "<none>".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!(" {}", "<none>".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.
|
||||
|
|
|
|||
158
src/main.rs
158
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<String>,
|
||||
},
|
||||
/// 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 <crate-dir>";
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<SummaryComplexityJson>,
|
||||
deps: SummaryDepsJson,
|
||||
graph: Option<SummaryGraphJson>,
|
||||
datapaths: Option<SummaryDatapathJson>,
|
||||
reachability: Option<SummaryReachabilityJson>,
|
||||
circuits: Option<SummaryCircuitsJson>,
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
203
src/symbols.rs
203
src/symbols.rs
|
|
@ -34,6 +34,38 @@ pub struct SymbolReport {
|
|||
pub files: Vec<FileSymbolCounts>,
|
||||
}
|
||||
|
||||
#[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<usize>,
|
||||
pub line_end: Option<usize>,
|
||||
}
|
||||
|
||||
#[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<FileSymbolRow>,
|
||||
}
|
||||
|
||||
pub fn analyze_symbols(symbols: &ProjectSymbols, project_path: &Path) -> SymbolReport {
|
||||
let mut files: Vec<FileSymbolCounts> = 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)
|
||||
}
|
||||
|
|
|
|||
166
tests/cluster_cli.rs
Normal file
166
tests/cluster_cli.rs
Normal file
|
|
@ -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<String>) -> 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<String>) -> 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::<Vec<_>>();
|
||||
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();
|
||||
}
|
||||
139
tests/coverage_cli.rs
Normal file
139
tests/coverage_cli.rs
Normal file
|
|
@ -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();
|
||||
}
|
||||
326
tests/file_summary_cli.rs
Normal file
326
tests/file_summary_cli.rs
Normal file
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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<P: VastAiApproval>(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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue