Compare commits

..

No commits in common. "33114847122813ccc7d63141495744b2a0013a40" and "f35326b5e8d07b77a797cc0e3733e1ec3e7ec76f" have entirely different histories.

8 changed files with 405 additions and 710 deletions

View file

@ -16,9 +16,7 @@ use crate::{circuits, complexity, datapaths, deps, flow, graph, loc, redundancy}
pub enum Command { pub enum Command {
Summary, Summary,
Loc { top: Option<usize> }, Loc { top: Option<usize> },
Branching, Complexity,
Signature,
Span,
Deps { top: Option<usize> }, Deps { top: Option<usize> },
Map, Map,
Flow, Flow,
@ -61,9 +59,7 @@ pub fn dispatch(command: Command, rs_files: &[PathBuf], project_path: &Path, jso
|| loc::render_loc_json(rs_files, &p), || loc::render_loc_json(rs_files, &p),
|| loc::render_loc(rs_files, &p, top, verbose), || loc::render_loc(rs_files, &p, top, verbose),
), ),
Command::Branching => complexity::run_branching(rs_files, &p, json, verbose), Command::Complexity => complexity::run(rs_files, &p, json, verbose),
Command::Signature => complexity::run_signature(rs_files, &p, json, verbose),
Command::Span => complexity::run_span(rs_files, &p, json, verbose),
Command::Deps { top } => run_json_or_human(json, Command::Deps { top } => run_json_or_human(json,
|| deps::render_deps_json(rs_files, &p), || deps::render_deps_json(rs_files, &p),
|| deps::render_deps(rs_files, &p, top, verbose), || deps::render_deps(rs_files, &p, top, verbose),

View file

@ -1,6 +1,6 @@
use colored::Colorize; use colored::Colorize;
use serde::Serialize; use serde::Serialize;
use std::path::{Path, PathBuf}; use std::path::Path;
use syn::visit::Visit; use syn::visit::Visit;
use syn::Expr; use syn::Expr;
@ -15,11 +15,27 @@ pub struct FunctionComplexity {
pub cyclomatic: usize, pub cyclomatic: usize,
pub cognitive: usize, pub cognitive: usize,
pub nesting_depth: usize, pub nesting_depth: usize,
pub generic_complexity: usize,
pub param_complexity: usize,
pub composite_score: f64, pub composite_score: f64,
pub line_count: usize, pub line_count: usize,
pub param_count: usize, pub param_count: usize,
} }
/// Complexity summary for a single file.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FileComplexity {
pub path: String,
pub function_count: usize,
pub total_complexity: f64,
pub mean_complexity: f64,
pub max_complexity: f64,
pub total_generics: usize,
pub trait_impl_count: usize,
pub derive_density: f64,
}
/// Aggregate stats across all functions. /// Aggregate stats across all functions.
pub struct ComplexityStats { pub struct ComplexityStats {
pub total_functions: usize, pub total_functions: usize,
@ -29,60 +45,47 @@ pub struct ComplexityStats {
pub max: f64, pub max: f64,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Serialize)]
pub struct BranchingRow { struct FunctionComplexityJson {
pub function: String, name: String,
pub file: String, file: String,
pub cyclomatic: usize, cyclomatic: usize,
pub branch_points: usize, cognitive: usize,
pub nesting_depth: usize, nesting_depth: usize,
} generic_complexity: usize,
param_complexity: usize,
#[derive(Debug, Clone, Serialize)] composite_score: f64,
pub struct SignatureRow { line_count: usize,
pub function: String, param_count: usize,
pub file: String,
pub param_count: usize,
pub return_type_complexity: usize,
pub generic_param_count: usize,
pub trait_bound_count: usize,
pub where_predicate_count: usize,
pub signature_score: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct SignatureFileRow {
pub file: String,
pub trait_impl_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct SpanRow {
pub function: String,
pub file: String,
pub line_count: usize,
pub body_stmt_count: usize,
pub line_start: usize,
pub line_end: usize,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct BranchingJson { struct FileComplexityJson {
cstat_version: String, path: String,
functions: Vec<BranchingRow>, function_count: usize,
total_complexity: f64,
mean_complexity: f64,
max_complexity: f64,
total_generics: usize,
trait_impl_count: usize,
derive_density: f64,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct SignatureJson { struct ComplexityStatsJson {
cstat_version: String, total_functions: usize,
functions: Vec<SignatureRow>, mean: f64,
files: Vec<SignatureFileRow>, std_dev: f64,
median: f64,
max: f64,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct SpanJson { struct ComplexityJson {
cstat_version: String, cstat_version: String,
functions: Vec<SpanRow>, functions: Vec<FunctionComplexityJson>,
files: Vec<FileComplexityJson>,
stats: Option<ComplexityStatsJson>,
} }
/// Compute complexity metrics for a single function. /// Compute complexity metrics for a single function.
@ -107,7 +110,11 @@ fn compute_function_complexity(func: &FunctionInfo, source: &str) -> FunctionCom
+ generic_complexity as f64 * 0.2 + generic_complexity as f64 * 0.2
+ param_complexity as f64 * 0.1; + param_complexity as f64 * 0.1;
let line_count = function_line_count(func); let line_count = if func.line_end >= func.line_start {
func.line_end - func.line_start + 1
} else {
func.body_stmt_count.max(1)
};
FunctionComplexity { FunctionComplexity {
name: func.name.clone(), name: func.name.clone(),
@ -115,6 +122,8 @@ fn compute_function_complexity(func: &FunctionInfo, source: &str) -> FunctionCom
cyclomatic, cyclomatic,
cognitive, cognitive,
nesting_depth: func.nesting_depth, nesting_depth: func.nesting_depth,
generic_complexity,
param_complexity,
composite_score, composite_score,
line_count, line_count,
param_count: func.param_count, param_count: func.param_count,
@ -367,6 +376,86 @@ pub fn compute_all(symbols: &ProjectSymbols, project_path: &Path) -> Vec<Functio
} }
/// Compute per-file complexity aggregation. /// Compute per-file complexity aggregation.
pub fn compute_file_complexity(
symbols: &ProjectSymbols,
func_complexities: &[FunctionComplexity],
project_path: &Path,
) -> Vec<FileComplexity> {
let mut file_map: std::collections::HashMap<String, Vec<&FunctionComplexity>> =
std::collections::HashMap::new();
for fc in func_complexities {
file_map.entry(fc.file.clone()).or_default().push(fc);
}
let mut file_complexities: Vec<FileComplexity> = symbols
.files
.iter()
.map(|file_syms| {
let path = strip_project_path(&file_syms.path.display().to_string(), project_path);
let funcs = file_map.get(&path).cloned().unwrap_or_default();
let function_count = funcs.len();
let scores: Vec<f64> = funcs.iter().map(|f| f.composite_score).collect();
let total_complexity: f64 = scores.iter().sum();
let mean_complexity = if function_count > 0 {
total_complexity / function_count as f64
} else {
0.0
};
let max_complexity = scores.iter().cloned().fold(0.0f64, f64::max);
let total_generics: usize = file_syms
.structs
.iter()
.map(|s| s.generic_param_count)
.sum::<usize>()
+ file_syms
.enums
.iter()
.map(|e| e.generic_param_count)
.sum::<usize>();
let trait_impl_count = file_syms
.impls
.iter()
.filter(|i| i.trait_name.is_some())
.count();
let total_types = file_syms.structs.len() + file_syms.enums.len();
let total_derives: usize = file_syms
.structs
.iter()
.map(|s| s.derive_count)
.sum::<usize>()
+ file_syms
.enums
.iter()
.map(|e| e.derive_count)
.sum::<usize>();
let derive_density = if total_types > 0 {
total_derives as f64 / total_types as f64
} else {
0.0
};
FileComplexity {
path,
function_count,
total_complexity,
mean_complexity,
max_complexity,
total_generics,
trait_impl_count,
derive_density,
}
})
.collect();
file_complexities.sort_by(|a, b| {
b.total_complexity
.partial_cmp(&a.total_complexity)
.unwrap_or(std::cmp::Ordering::Equal)
});
file_complexities
}
/// Compute aggregate stats across all function complexities. /// Compute aggregate stats across all function complexities.
pub fn compute_stats(complexities: &[FunctionComplexity]) -> Option<ComplexityStats> { pub fn compute_stats(complexities: &[FunctionComplexity]) -> Option<ComplexityStats> {
@ -406,380 +495,283 @@ fn strip_project_path(path: &str, project_path: &Path) -> String {
stripped.to_string() stripped.to_string()
} }
fn relative_file(path: &Path, project_path: &Path) -> String { /// Render the function complexity rankings table.
strip_project_path(&path.display().to_string(), project_path) fn render_function_rankings(
} func_complexities: &[FunctionComplexity],
_term_w: usize,
verbose: bool,
) {
println!(
"\n{}",
"\u{2500}\u{2500} Function Complexity Rankings \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
.bright_cyan()
.bold()
);
fn function_line_count(func: &FunctionInfo) -> usize {
if func.line_end >= func.line_start {
func.line_end - func.line_start + 1
} else {
func.body_stmt_count.max(1)
}
}
fn signature_score(func: &FunctionInfo) -> usize {
func.param_count
+ func.return_type_complexity
+ func.generic_param_count
+ func.trait_bound_count
+ func.where_predicate_count
}
fn truncate(value: &str, max: usize) -> String {
if value.chars().count() <= max {
return value.to_string();
}
let keep = max.saturating_sub(3);
let mut out = value.chars().take(keep).collect::<String>();
out.push_str("...");
out
}
pub fn collect_branching(symbols: &ProjectSymbols, project_path: &Path) -> Vec<BranchingRow> {
let mut rows: Vec<BranchingRow> = symbols
.files
.iter()
.flat_map(|file| file.functions.iter())
.map(|func| BranchingRow {
function: func.name.clone(),
file: relative_file(&func.file, project_path),
cyclomatic: func.branch_points + 1,
branch_points: func.branch_points,
nesting_depth: func.nesting_depth,
})
.collect();
rows.sort_by(|a, b| {
b.cyclomatic
.cmp(&a.cyclomatic)
.then_with(|| b.branch_points.cmp(&a.branch_points))
.then_with(|| b.nesting_depth.cmp(&a.nesting_depth))
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.function.cmp(&b.function))
});
rows
}
pub fn collect_signatures(symbols: &ProjectSymbols, project_path: &Path) -> Vec<SignatureRow> {
let mut rows: Vec<SignatureRow> = symbols
.files
.iter()
.flat_map(|file| file.functions.iter())
.map(|func| SignatureRow {
function: func.name.clone(),
file: relative_file(&func.file, project_path),
param_count: func.param_count,
return_type_complexity: func.return_type_complexity,
generic_param_count: func.generic_param_count,
trait_bound_count: func.trait_bound_count,
where_predicate_count: func.where_predicate_count,
signature_score: signature_score(func),
})
.collect();
rows.sort_by(|a, b| {
b.signature_score
.cmp(&a.signature_score)
.then_with(|| b.param_count.cmp(&a.param_count))
.then_with(|| b.return_type_complexity.cmp(&a.return_type_complexity))
.then_with(|| b.generic_param_count.cmp(&a.generic_param_count))
.then_with(|| b.trait_bound_count.cmp(&a.trait_bound_count))
.then_with(|| b.where_predicate_count.cmp(&a.where_predicate_count))
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.function.cmp(&b.function))
});
rows
}
pub fn collect_signature_files(
symbols: &ProjectSymbols,
project_path: &Path,
) -> Vec<SignatureFileRow> {
let mut rows: Vec<SignatureFileRow> = symbols
.files
.iter()
.map(|file_syms| SignatureFileRow {
file: relative_file(&file_syms.path, project_path),
trait_impl_count: file_syms
.impls
.iter()
.filter(|imp| imp.trait_name.is_some())
.count(),
})
.collect();
rows.sort_by(|a, b| {
b.trait_impl_count
.cmp(&a.trait_impl_count)
.then_with(|| a.file.cmp(&b.file))
});
rows
}
pub fn collect_spans(symbols: &ProjectSymbols, project_path: &Path) -> Vec<SpanRow> {
let mut rows: Vec<SpanRow> = symbols
.files
.iter()
.flat_map(|file| file.functions.iter())
.map(|func| SpanRow {
function: func.name.clone(),
file: relative_file(&func.file, project_path),
line_count: function_line_count(func),
body_stmt_count: func.body_stmt_count,
line_start: func.line_start,
line_end: func.line_end,
})
.collect();
rows.sort_by(|a, b| {
b.line_count
.cmp(&a.line_count)
.then_with(|| b.body_stmt_count.cmp(&a.body_stmt_count))
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.function.cmp(&b.function))
});
rows
}
pub fn render_branching(symbols: &ProjectSymbols, project_path: &Path, verbose: bool) {
let rows = collect_branching(symbols, project_path);
if rows.is_empty() {
println!("{}", "No functions found to analyze.".yellow());
return;
}
render::section_header("Function Branching Complexity");
if verbose { if verbose {
render::verbose_block(&[ render::verbose_block(&[
"Functions ranked by decision/path complexity.", "Functions ranked by composite complexity score (highest = most complex).",
"Cyclomatic complexity is branch_points + 1.", "Each column is an individual complexity metric:",
"Branch points and nesting depth come from the parsed control-flow visitor.", " Cycl = cyclomatic complexity = number of linearly independent paths",
"cognitive and composite_score are intentionally not part of this view.", " through the function (branch points + 1). Higher = more branches.",
" Cogn = cognitive complexity (SonarSource model): +1 per control flow break",
" (if, for, while, match, &&, ||), plus a nesting penalty per level.",
" Nest = deepest nesting depth in the function body.",
" Gen = generic complexity = type params + trait bounds + where predicates.",
" Param = parameter count + return type complexity.",
" Score = composite: 1.0\u{00d7}Cycl + 0.5\u{00d7}Cogn + 0.3\u{00d7}Nest + 0.2\u{00d7}Gen + 0.1\u{00d7}Param.",
"Color: red = highest score, green = lowest. Functions sorted descending by Score.",
]); ]);
render::guide_ref("complexity");
} }
let display_count = rows.len().min(20); let display_count = func_complexities.len().min(20);
println!( println!(
"{} functions analyzed, showing top {}\n", "{} functions analyzed, showing top {}\n",
format!("{}", rows.len()).bold(), format!("{}", func_complexities.len()).bold(),
format!("{}", display_count).bold(), format!("{}", display_count).bold(),
); );
// Header
println!( println!(
" {:<40} {:<30} {:>5} {:>8} {:>5}", " {:<40} {:>5} {:>5} {:>5} {:>5} {:>5} {:>7}",
"Function".bold().underline(), "Function".bold().underline(),
"File".bold().underline(),
"Cycl".bold().underline(), "Cycl".bold().underline(),
"Branches".bold().underline(), "Cogn".bold().underline(),
"Nest".bold().underline(), "Nest".bold().underline(),
"Gen".bold().underline(),
"Param".bold().underline(),
"Score".bold().underline(),
); );
for row in rows.iter().take(display_count) { for (i, fc) in func_complexities.iter().take(display_count).enumerate() {
let ratio = i as f64 / display_count.max(1) as f64;
let name_display = if fc.name.len() > 38 {
format!("{}...", &fc.name[..35])
} else {
fc.name.clone()
};
let score_str = format!("{:.1}", fc.composite_score);
let colored_score = render::bar_color(&score_str, ratio);
println!( println!(
" {:<40} {:<30} {:>5} {:>8} {:>5}", " {:<40} {:>5} {:>5} {:>5} {:>5} {:>5} {}",
truncate(&row.function, 40).dimmed(), name_display.dimmed(),
truncate(&row.file, 30).dimmed(), fc.cyclomatic,
row.cyclomatic, fc.cognitive,
row.branch_points, fc.nesting_depth,
row.nesting_depth, fc.generic_complexity,
fc.param_complexity,
colored_score,
); );
} }
if rows.len() > display_count { if func_complexities.len() > display_count {
println!( println!(
"{}", "{}",
format!(" ... and {} more functions", rows.len() - display_count).dimmed() format!(
" ... and {} more functions",
func_complexities.len() - display_count
)
.dimmed()
); );
} }
println!();
} }
pub fn render_branching_json(symbols: &ProjectSymbols, project_path: &Path) { /// Render the per-file complexity bar chart.
let output = BranchingJson { fn render_file_complexity_chart(
cstat_version: env!("CARGO_PKG_VERSION").to_string(), file_complexities: &[FileComplexity],
functions: collect_branching(symbols, project_path), term_w: usize,
}; verbose: bool,
) {
println!(
"\n{}",
"\u{2500}\u{2500} Per-File Complexity \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
.bright_cyan()
.bold()
);
println!("{}", serde_json::to_string(&output).unwrap());
}
pub fn render_signature(symbols: &ProjectSymbols, project_path: &Path, verbose: bool) {
let rows = collect_signatures(symbols, project_path);
let file_rows = collect_signature_files(symbols, project_path);
render::section_header("Function Signature Complexity");
if verbose { if verbose {
render::verbose_block(&[ render::verbose_block(&[
"Functions ranked by API-boundary complexity.", "Total composite complexity score per file (sum of all function scores in the file).",
"Signature score = params + return type complexity + generic params + trait bounds + where predicates.", "Bar length is proportional to the file with the highest total score.",
"This view answers call/satisfy/reason-about difficulty, not control-flow or implementation span.", "Color: red = top of ranking, green = bottom. Files sorted descending.",
]); ]);
} }
if rows.is_empty() { let file_display_count = file_complexities.len().min(20);
println!("{}", "No functions found to analyze.".yellow()); let display_files = &file_complexities[..file_display_count];
} else {
let display_count = rows.len().min(20);
println!(
"{} functions analyzed, showing top {}\n",
format!("{}", rows.len()).bold(),
format!("{}", display_count).bold(),
);
println!(
" {:<34} {:<24} {:>6} {:>6} {:>8} {:>6} {:>5} {:>5}",
"Function".bold().underline(),
"File".bold().underline(),
"Params".bold().underline(),
"Return".bold().underline(),
"Generics".bold().underline(),
"Bounds".bold().underline(),
"Where".bold().underline(),
"Score".bold().underline(),
);
for row in rows.iter().take(display_count) { let max_label_len = display_files
println!( .iter()
" {:<34} {:<24} {:>6} {:>6} {:>8} {:>6} {:>5} {:>5}", .map(|f| f.path.len())
truncate(&row.function, 34).dimmed(), .max()
truncate(&row.file, 24).dimmed(), .unwrap_or(0)
row.param_count, .min(40);
row.return_type_complexity,
row.generic_param_count,
row.trait_bound_count,
row.where_predicate_count,
row.signature_score,
);
}
if rows.len() > display_count { let max_score = display_files
println!( .iter()
"{}", .map(|f| f.total_complexity)
format!(" ... and {} more functions", rows.len() - display_count).dimmed() .fold(0.0f64, f64::max);
);
} let score_digits = format!("{:.1}", max_score).len();
} let bar_budget = term_w
.saturating_sub(max_label_len)
.saturating_sub(3) // " \u{2502} "
.saturating_sub(score_digits)
.saturating_sub(2)
.max(10);
if !file_rows.is_empty() {
render::section_header("Trait Implementations by File");
let display_count = file_rows.len().min(20);
println!(
" {:<40} {:>10}",
"File".bold().underline(),
"TraitImpls".bold().underline(),
);
for row in file_rows.iter().take(display_count) {
println!(
" {:<40} {:>10}",
truncate(&row.file, 40).dimmed(),
row.trait_impl_count,
);
}
if file_rows.len() > display_count {
println!(
"{}",
format!(" ... and {} more files", file_rows.len() - display_count).dimmed()
);
}
}
println!(); println!();
for (i, fc) in display_files.iter().enumerate() {
let ratio = i as f64 / file_display_count.max(1) as f64;
let label = if fc.path.len() > 40 {
format!("...{}", &fc.path[fc.path.len() - 37..])
} else {
fc.path.clone()
};
let bar_len = if max_score > 0.0 {
(fc.total_complexity / max_score * bar_budget as f64).ceil() as usize
} else {
0
}
.max(if fc.total_complexity > 0.0 { 1 } else { 0 });
let bar_str = "\u{2588}".repeat(bar_len);
let colored_bar = render::bar_color(&bar_str, ratio);
println!(
"{:>width$} \u{2502} {}{} {}",
label.dimmed(),
colored_bar,
" ".repeat(bar_budget.saturating_sub(bar_len)),
format!("{:.1}", fc.total_complexity).bold(),
width = max_label_len,
);
}
if file_complexities.len() > file_display_count {
println!(
"{}",
format!(
" ... and {} more files",
file_complexities.len() - file_display_count
)
.dimmed()
);
}
} }
pub fn render_signature_json(symbols: &ProjectSymbols, project_path: &Path) { /// Render the aggregate complexity statistics box.
let output = SignatureJson { fn render_complexity_stats(stats: &ComplexityStats, verbose: bool) {
cstat_version: env!("CARGO_PKG_VERSION").to_string(), println!(
functions: collect_signatures(symbols, project_path), "\n{}",
files: collect_signature_files(symbols, project_path), "\u{2500}\u{2500} Complexity Statistics \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
}; .bright_cyan()
.bold()
);
println!("{}", serde_json::to_string(&output).unwrap()); if verbose {
render::verbose_block(&[
"Aggregate statistics for composite complexity across all functions.",
"Mean = average score. Std Dev = spread. Median = middle value (robust to outliers).",
"Max = highest individual function score in the project.",
]);
}
println!("\u{250c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{252c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2510}");
render::print_stat_row_wide("Total functions", &format!("{}", stats.total_functions));
render::print_stat_row_wide("Mean score", &format!("{:.2}", stats.mean));
render::print_stat_row_wide("Std Dev", &format!("{:.2}", stats.std_dev));
render::print_stat_row_wide("Median score", &format!("{:.2}", stats.median));
render::print_stat_row_wide("Max score", &format!("{:.2}", stats.max));
println!("\u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2534}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2518}");
} }
pub fn render_span(symbols: &ProjectSymbols, project_path: &Path, verbose: bool) { /// Render the complexity subcommand output.
let rows = collect_spans(symbols, project_path); pub fn render_complexity(symbols: &ProjectSymbols, project_path: &Path, verbose: bool) {
if rows.is_empty() { let func_complexities = compute_all(symbols, project_path);
if func_complexities.is_empty() {
println!("{}", "No functions found to analyze.".yellow()); println!("{}", "No functions found to analyze.".yellow());
return; return;
} }
render::section_header("Function Implementation Span"); let file_complexities = compute_file_complexity(symbols, &func_complexities, project_path);
if verbose { let stats = compute_stats(&func_complexities);
render::verbose_block(&[ let term_w = render::terminal_width();
"Functions ranked by implementation-unit size.",
"line_count is the inclusive function-local line_start..=line_end span.", render_function_rankings(&func_complexities, term_w, verbose);
"This does not replace cstat loc, which remains file/project LOC.", render_file_complexity_chart(&file_complexities, term_w, verbose);
]);
if let Some(ref stats) = stats {
render_complexity_stats(stats, verbose);
} }
let display_count = rows.len().min(20);
println!(
"{} functions analyzed, showing top {}\n",
format!("{}", rows.len()).bold(),
format!("{}", display_count).bold(),
);
println!(
" {:<40} {:<30} {:>5} {:>5} {:>5} {:>5}",
"Function".bold().underline(),
"File".bold().underline(),
"Lines".bold().underline(),
"Stmts".bold().underline(),
"Start".bold().underline(),
"End".bold().underline(),
);
for row in rows.iter().take(display_count) {
println!(
" {:<40} {:<30} {:>5} {:>5} {:>5} {:>5}",
truncate(&row.function, 40).dimmed(),
truncate(&row.file, 30).dimmed(),
row.line_count,
row.body_stmt_count,
row.line_start,
row.line_end,
);
}
if rows.len() > display_count {
println!(
"{}",
format!(" ... and {} more functions", rows.len() - display_count).dimmed()
);
}
println!(); println!();
} }
pub fn render_span_json(symbols: &ProjectSymbols, project_path: &Path) { /// Render complexity analysis as JSON.
let output = SpanJson { pub fn render_complexity_json(symbols: &ProjectSymbols, project_path: &Path) {
let func_complexities = compute_all(symbols, project_path);
let file_complexities = compute_file_complexity(symbols, &func_complexities, project_path);
let stats = compute_stats(&func_complexities);
let functions: Vec<FunctionComplexityJson> = func_complexities
.iter()
.map(|fc| FunctionComplexityJson {
name: fc.name.clone(),
file: fc.file.clone(),
cyclomatic: fc.cyclomatic,
cognitive: fc.cognitive,
nesting_depth: fc.nesting_depth,
generic_complexity: fc.generic_complexity,
param_complexity: fc.param_complexity,
composite_score: fc.composite_score,
line_count: fc.line_count,
param_count: fc.param_count,
})
.collect();
let files: Vec<FileComplexityJson> = file_complexities
.iter()
.map(|fc| FileComplexityJson {
path: fc.path.clone(),
function_count: fc.function_count,
total_complexity: fc.total_complexity,
mean_complexity: fc.mean_complexity,
max_complexity: fc.max_complexity,
total_generics: fc.total_generics,
trait_impl_count: fc.trait_impl_count,
derive_density: fc.derive_density,
})
.collect();
let stats_json = stats.map(|s| ComplexityStatsJson {
total_functions: s.total_functions,
mean: s.mean,
std_dev: s.std_dev,
median: s.median,
max: s.max,
});
let output = ComplexityJson {
cstat_version: env!("CARGO_PKG_VERSION").to_string(), cstat_version: env!("CARGO_PKG_VERSION").to_string(),
functions: collect_spans(symbols, project_path), functions,
files,
stats: stats_json,
}; };
println!("{}", serde_json::to_string(&output).unwrap()); println!("{}", serde_json::to_string(&output).unwrap());
} }
pub fn run_branching(rs_files: &[PathBuf], project_path: &Path, json: bool, verbose: bool) { pub fn run(rs_files: &[std::path::PathBuf], project_path: &Path, json: bool, verbose: bool) {
let symbols = crate::ast_parser::parse_project(rs_files); let symbols = crate::ast_parser::parse_project(rs_files);
if json { if json {
render_branching_json(&symbols, project_path); render_complexity_json(&symbols, project_path);
} else { } else {
render_branching(&symbols, project_path, verbose); render_complexity(&symbols, project_path, verbose);
}
}
pub fn run_signature(rs_files: &[PathBuf], project_path: &Path, json: bool, verbose: bool) {
let symbols = crate::ast_parser::parse_project(rs_files);
if json {
render_signature_json(&symbols, project_path);
} else {
render_signature(&symbols, project_path, verbose);
}
}
pub fn run_span(rs_files: &[PathBuf], project_path: &Path, json: bool, verbose: bool) {
let symbols = crate::ast_parser::parse_project(rs_files);
if json {
render_span_json(&symbols, project_path);
} else {
render_span(&symbols, project_path, verbose);
} }
} }

View file

@ -180,11 +180,15 @@ fn build_drilldowns(diagnostics: &[diagnostics::Diagnostic]) -> Vec<Drilldown> {
// Map categories to subcommands // Map categories to subcommands
let category_to_command: &[(&[&str], &str)] = &[ let category_to_command: &[(&[&str], &str)] = &[
( (
&["high_complexity", "high_cognitive", "deep_nesting"], &[
"cstat branching --json", "bloated_function",
"high_complexity",
"high_cognitive",
"deep_nesting",
"too_many_params",
],
"cstat complexity --json",
), ),
(&["too_many_params"], "cstat signature --json"),
(&["bloated_function"], "cstat span --json"),
(&["redundant_code"], "cstat redundancy --json"), (&["redundant_code"], "cstat redundancy --json"),
(&["bloated_file"], "cstat loc --json"), (&["bloated_file"], "cstat loc --json"),
( (

View file

@ -52,7 +52,7 @@ or `cstat dump` for a machine-readable JSON diagnostic report.",
], ],
patterns: &[ patterns: &[
"Human workflow: run summary first, read the dashboard, drill into specific \ "Human workflow: run summary first, read the dashboard, drill into specific \
dimensions with loc/branching/signature/span/deps/flow/graph as needed.", dimensions with loc/complexity/deps/flow/graph as needed.",
"Agent workflow: run dump for the full diagnostic picture, read scores to \ "Agent workflow: run dump for the full diagnostic picture, read scores to \
identify weak dimensions, use individual commands with --json to explore specifics.", identify weak dimensions, use individual commands with --json to explore specifics.",
"The -v (verbose) flag adds contextual explanations to any command — useful \ "The -v (verbose) flag adds contextual explanations to any command — useful \
@ -96,23 +96,22 @@ Size metrics measure the volume and distribution of Rust source. `cstat loc` own
fn topic_complexity() -> TopicContent { fn topic_complexity() -> TopicContent {
TopicContent { TopicContent {
description: "\ description: "\
Complexity metrics are split by question: branching for decision/path shape, \ Complexity metrics quantify different aspects of how difficult code is to \
signature for API-boundary difficulty, and span for function-local implementation \ understand, test, and modify. No single metric captures the full picture — \
size. No single metric captures the full picture — cyclomatic counts paths, \ cyclomatic counts paths, cognitive models reading difficulty, nesting measures \
cognitive models reading difficulty in diagnostics/distributions, nesting measures \
structural depth, and parameter count reflects interface width.", structural depth, and parameter count reflects interface width.",
metrics: &[ metrics: &[
("cyclomatic complexity", "Number of linearly independent paths through a function (branch_points + 1). Higher values mean more test cases needed for full coverage."), ("cyclomatic complexity", "Number of linearly independent paths through a function (branch_points + 1). Higher values mean more test cases needed for full coverage."),
("cognitive complexity", "SonarSource model: +1 per control flow break, +nesting_level penalty. Used by diagnostics/distributions, not exposed as a top-level probe."), ("cognitive complexity", "SonarSource model: +1 per control flow break, +nesting_level penalty. Models how hard the code is for a human to read."),
("nesting depth", "Maximum depth of nested control structures. Deep nesting forces readers to hold more context in working memory."), ("nesting depth", "Maximum depth of nested control structures. Deep nesting forces readers to hold more context in working memory."),
("parameter count", "Number of function parameters. Wide interfaces are harder to call correctly and may indicate a function doing too much."), ("parameter count", "Number of function parameters. Wide interfaces are harder to call correctly and may indicate a function doing too much."),
("function span", "Inclusive function-local line range and top-level body statement count. This complements file/project LOC."), ("composite score", "Weighted combination: 1.0×cyclomatic + 0.5×cognitive + 0.3×nesting + 0.2×generic + 0.1×param. Useful for ranking."),
], ],
commands: &[ commands: &[
"cstat branching — decision/path complexity by function", "cstat complexity — per-function and per-file complexity rankings",
"cstat signature — API-boundary complexity by function and file", "cstat complexity --json — structured complexity data for all functions",
"cstat span — function-local implementation size and line ranges", "cstat dist --metric cyclomatic — distribution and outlier analysis",
"cstat advanced dist --metric cyclomatic — distribution and outlier analysis", "cstat dist --metric cognitive — cognitive complexity distribution",
], ],
patterns: &[ patterns: &[
"High cyclomatic but low cognitive complexity usually means straightforward \ "High cyclomatic but low cognitive complexity usually means straightforward \
@ -120,8 +119,8 @@ branching (e.g. match statements with simple arms). High cognitive with \
moderate cyclomatic often means nested conditionals.", moderate cyclomatic often means nested conditionals.",
"Functions with nesting depth > 4 are nearly always worth refactoring — \ "Functions with nesting depth > 4 are nearly always worth refactoring — \
extract inner blocks into helper functions to flatten the structure.", extract inner blocks into helper functions to flatten the structure.",
"Use branching, signature, and span together: a function can be easy to call \ "The composite score is useful for triage: sort by composite to find functions \
but branch-heavy, short but hard to satisfy generically, or long without many decisions.", that are complex along multiple dimensions simultaneously.",
], ],
} }
} }

View file

@ -93,12 +93,8 @@ Use --explain to print this usage and JSON field contract without running analys
}, },
/// Rust AST symbol counts by kind, including trait impl blocks /// Rust AST symbol counts by kind, including trait impl blocks
Symbols, Symbols,
/// Function decision/path complexity rankings /// Per-function and per-file complexity rankings
Branching, Complexity,
/// Function API boundary complexity rankings
Signature,
/// Function implementation span rankings
Span,
/// Module dependency connectome /// Module dependency connectome
Deps { Deps {
/// Show only the top N most connected modules /// Show only the top N most connected modules
@ -246,14 +242,8 @@ fn main() {
symbols::render_symbols_file(project_rs_files, project_path, file, verbose); symbols::render_symbols_file(project_rs_files, project_path, file, verbose);
} }
} }
Commands::Branching => { Commands::Complexity => {
complexity::run_branching(target_rs_files, project_path, json, verbose); complexity::run(target_rs_files, project_path, json, verbose);
}
Commands::Signature => {
complexity::run_signature(target_rs_files, project_path, json, verbose);
}
Commands::Span => {
complexity::run_span(target_rs_files, project_path, json, verbose);
} }
Commands::Cluster => { Commands::Cluster => {
if json { if json {
@ -364,14 +354,8 @@ fn main() {
symbols::render_symbols(&rs_files, &project_path, verbose); symbols::render_symbols(&rs_files, &project_path, verbose);
} }
} }
Commands::Branching => { Commands::Complexity => {
complexity::run_branching(&rs_files, &project_path, json, verbose); complexity::run(&rs_files, &project_path, json, verbose);
}
Commands::Signature => {
complexity::run_signature(&rs_files, &project_path, json, verbose);
}
Commands::Span => {
complexity::run_span(&rs_files, &project_path, json, verbose);
} }
Commands::Cluster => { Commands::Cluster => {
cluster::render_cluster(&rs_files, &project_path, json, verbose); cluster::render_cluster(&rs_files, &project_path, json, verbose);

View file

@ -133,6 +133,11 @@ pub fn print_stat_row(label: &str, value: &str) {
println!("│ {:<11} │ {:>12} │", label.cyan(), value.bold()); println!("│ {:<11} │ {:>12} │", label.cyan(), value.bold());
} }
/// Print a stat row with 16-char label column (used by complexity).
pub fn print_stat_row_wide(label: &str, value: &str) {
println!("│ {:<16} │ {:>12} │", label.cyan(), value.bold());
}
/// Print a verbose explanation line with dimmed cyan ℹ prefix. /// Print a verbose explanation line with dimmed cyan ℹ prefix.
pub fn verbose_line(text: &str) { pub fn verbose_line(text: &str) {
println!(" {}", format!("ℹ {}", text).dimmed()); println!(" {}", format!("ℹ {}", text).dimmed());

View file

@ -193,53 +193,51 @@ fn render_loc_bars(file_stats: &[loc::FileLocStats], project_path_canon: &Path,
} }
} }
/// Render the branching hotspots table. /// Render the complexity hotspots table.
fn render_complexity_section(func_complexities: &[complexity::FunctionComplexity], verbose: bool) { fn render_complexity_section(func_complexities: &[complexity::FunctionComplexity], verbose: bool) {
render::section_header("Branching Hotspots"); render::section_header("Complexity Hotspots");
if verbose { if verbose {
render::verbose_block(&[ render::verbose_block(&[
"Functions ranked by cyclomatic branching complexity (highest first).", "Functions ranked by composite complexity score (highest first).",
"Composite Score = 1.0×Cyclomatic + 0.5×Cognitive + 0.3×Nesting + 0.2×Generics + 0.1×Params.",
"Cycl = cyclomatic complexity = branch points + 1 (linearly independent paths).", "Cycl = cyclomatic complexity = branch points + 1 (linearly independent paths).",
"Branches = cyclomatic - 1. Nest = deepest nesting depth within the function body.", "Cogn = cognitive complexity (SonarSource model): +1 per control flow break, +nesting penalty.",
"See 'cstat branching', 'cstat signature', and 'cstat span' for focused detail.", "Nest = deepest nesting depth within the function body.",
"Score column: red = highest complexity, green = lowest. See 'cstat complexity' for full detail.",
]); ]);
} }
if func_complexities.is_empty() { if func_complexities.is_empty() {
println!(" {}", "No functions found.".dimmed()); println!(" {}", "No functions found.".dimmed());
} else { } else {
let mut rows: Vec<_> = func_complexities.iter().collect(); let display_count = func_complexities.len().min(10);
rows.sort_by(|a, b| {
b.cyclomatic
.cmp(&a.cyclomatic)
.then_with(|| b.nesting_depth.cmp(&a.nesting_depth))
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.name.cmp(&b.name))
});
let display_count = rows.len().min(10);
println!( println!(
" {:<36} {:>5} {:>8} {:>5}", " {:<36} {:>5} {:>5} {:>5} {:>7}",
"Function".bold().underline(), "Function".bold().underline(),
"Cycl".bold().underline(), "Cycl".bold().underline(),
"Branches".bold().underline(), "Cogn".bold().underline(),
"Nest".bold().underline(), "Nest".bold().underline(),
"Score".bold().underline(),
); );
for fc in rows.into_iter().take(display_count) { for (i, fc) in func_complexities.iter().take(display_count).enumerate() {
let ratio = i as f64 / display_count.max(1) as f64;
let name = if fc.name.len() > 34 { let name = if fc.name.len() > 34 {
format!("{}...", &fc.name[..31]) format!("{}...", &fc.name[..31])
} else { } else {
fc.name.clone() fc.name.clone()
}; };
let score_str = format!("{:.1}", fc.composite_score);
let colored_score = render::bar_color(&score_str, ratio);
println!( println!(
" {:<36} {:>5} {:>8} {:>5}", " {:<36} {:>5} {:>5} {:>5} {}",
name.dimmed(), name.dimmed(),
fc.cyclomatic, fc.cyclomatic,
fc.cyclomatic.saturating_sub(1), fc.cognitive,
fc.nesting_depth, fc.nesting_depth,
colored_score,
); );
} }
if func_complexities.len() > display_count { if func_complexities.len() > display_count {

View file

@ -1,283 +0,0 @@
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};
const FIXTURE: &str = r#"pub trait Worker {
fn work(&self) -> usize;
}
pub struct Engine<T> {
value: T,
}
impl<T> Worker for Engine<T>
where
T: Copy + Into<usize>,
{
fn work(&self) -> usize {
self.value.into()
}
}
pub fn simple() -> usize {
1
}
pub fn branchy(input: i32) -> i32 {
let mut total = 0;
if input > 0 {
total += 1;
}
match input {
0 => total += 10,
1 | 2 => total += 20,
_ => total += 30,
}
for i in 0..input {
if i % 2 == 0 {
total += i;
}
}
total
}
pub fn hard_signature<T, U>(
first: T,
second: U,
count: usize,
) -> Result<Option<(T, U)>, String>
where
T: Clone + Into<String>,
U: Default,
{
if count == 0 {
return Err(String::new());
}
Ok(Some((first, second)))
}
"#;
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-function-probes-{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"), FIXTURE).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={:?} stdout={} stderr={}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn parse_json(output: Output) -> Value {
assert_success(&output);
serde_json::from_slice(&output.stdout).expect("parse cstat json")
}
fn function_row<'a>(value: &'a Value, function: &str) -> &'a Value {
value["functions"]
.as_array()
.expect("functions array")
.iter()
.find(|row| row["function"] == function)
.unwrap_or_else(|| panic!("missing function row {function}: {value}"))
}
fn assert_absent(row: &Value, keys: &[&str]) {
for key in keys {
assert!(row.get(*key).is_none(), "unexpected key {key}: {row}");
}
}
#[test]
fn branching_json_reports_decision_path_fields() {
let root = temp_project("branching-json");
let value = parse_json(run_cstat(&root, &["--json", "branching"]));
let row = function_row(&value, "branchy");
assert_eq!(row["file"], "src/lib.rs");
assert_eq!(row["branch_points"], 5);
assert_eq!(row["cyclomatic"], 6);
assert_eq!(row["nesting_depth"], 2);
assert_absent(
row,
&[
"cognitive",
"generic_complexity",
"param_complexity",
"composite_score",
],
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn signature_json_reports_api_boundary_fields() {
let root = temp_project("signature-json");
let value = parse_json(run_cstat(&root, &["--json", "signature"]));
let row = function_row(&value, "hard_signature");
assert_eq!(row["file"], "src/lib.rs");
assert_eq!(row["param_count"], 3);
assert_eq!(row["return_type_complexity"], 6);
assert_eq!(row["generic_param_count"], 2);
assert_eq!(row["trait_bound_count"], 3);
assert_eq!(row["where_predicate_count"], 2);
assert_eq!(row["signature_score"], 16);
assert_absent(
row,
&["cyclomatic", "cognitive", "line_count", "composite_score"],
);
let file_row = value["files"]
.as_array()
.expect("files array")
.iter()
.find(|row| row["file"] == "src/lib.rs")
.unwrap_or_else(|| panic!("missing file row: {value}"));
assert_eq!(file_row["trait_impl_count"], 1);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn span_json_reports_body_size_and_line_ranges() {
let root = temp_project("span-json");
let value = parse_json(run_cstat(&root, &["--json", "span"]));
let row = function_row(&value, "branchy");
assert_eq!(row["file"], "src/lib.rs");
assert_eq!(row["line_start"], 22);
assert_eq!(row["line_end"], 38);
assert_eq!(row["line_count"], 17);
assert_eq!(row["body_stmt_count"], 5);
assert_absent(
row,
&[
"cyclomatic",
"cognitive",
"signature_score",
"composite_score",
],
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn probe_human_outputs_use_focused_headings() {
let root = temp_project("human");
let branching = run_cstat(&root, &["branching"]);
assert_success(&branching);
let branching_stdout = String::from_utf8_lossy(&branching.stdout);
assert!(
branching_stdout.contains("Function Branching Complexity"),
"stdout={branching_stdout}"
);
assert!(
!branching_stdout.contains("Score"),
"stdout={branching_stdout}"
);
let signature = run_cstat(&root, &["signature"]);
assert_success(&signature);
let signature_stdout = String::from_utf8_lossy(&signature.stdout);
assert!(
signature_stdout.contains("Function Signature Complexity"),
"stdout={signature_stdout}"
);
assert!(
!signature_stdout.contains("Cycl"),
"stdout={signature_stdout}"
);
let span = run_cstat(&root, &["span"]);
assert_success(&span);
let span_stdout = String::from_utf8_lossy(&span.stdout);
assert!(
span_stdout.contains("Function Implementation Span"),
"stdout={span_stdout}"
);
assert!(!span_stdout.contains("Signature"), "stdout={span_stdout}");
fs::remove_dir_all(root).unwrap();
}
#[test]
fn selected_file_paths_scope_probe_json() {
let root = temp_project("selected-file");
let file = root.join("src/lib.rs");
for command in ["branching", "signature", "span"] {
let value = parse_json(run_cstat(&file, &["--json", command]));
let functions = value["functions"].as_array().expect("functions array");
assert!(!functions.is_empty(), "no functions for {command}: {value}");
for row in functions {
assert_eq!(row["file"], "src/lib.rs", "row={row}");
}
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn old_complexity_command_is_not_available() {
let root = temp_project("old-complexity");
let output = run_cstat(&root, &["complexity"]);
assert!(
!output.status.success(),
"old complexity command unexpectedly succeeded: stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn help_lists_function_probe_commands() {
let root = temp_project("help");
let output = run_cstat(&root, &["--help"]);
assert_success(&output);
let stdout = String::from_utf8_lossy(&output.stdout);
for command in ["branching", "signature", "span"] {
assert!(
stdout.contains(command),
"root help missing {command}: {stdout}"
);
}
assert!(
!stdout.lines().any(|line| line.starts_with(" complexity")),
"root help still listed complexity: {stdout}"
);
fs::remove_dir_all(root).unwrap();
}