Merge branch 'complexity'
This commit is contained in:
commit
3311484712
8 changed files with 739 additions and 434 deletions
|
|
@ -16,7 +16,9 @@ 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> },
|
||||||
Complexity,
|
Branching,
|
||||||
|
Signature,
|
||||||
|
Span,
|
||||||
Deps { top: Option<usize> },
|
Deps { top: Option<usize> },
|
||||||
Map,
|
Map,
|
||||||
Flow,
|
Flow,
|
||||||
|
|
@ -59,7 +61,9 @@ 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::Complexity => complexity::run(rs_files, &p, json, verbose),
|
Command::Branching => complexity::run_branching(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),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use syn::visit::Visit;
|
use syn::visit::Visit;
|
||||||
use syn::Expr;
|
use syn::Expr;
|
||||||
|
|
||||||
|
|
@ -15,27 +15,11 @@ 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,
|
||||||
|
|
@ -45,47 +29,60 @@ pub struct ComplexityStats {
|
||||||
pub max: f64,
|
pub max: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct FunctionComplexityJson {
|
pub struct BranchingRow {
|
||||||
name: String,
|
pub function: String,
|
||||||
file: String,
|
pub file: String,
|
||||||
cyclomatic: usize,
|
pub cyclomatic: usize,
|
||||||
cognitive: usize,
|
pub branch_points: usize,
|
||||||
nesting_depth: usize,
|
pub nesting_depth: usize,
|
||||||
generic_complexity: usize,
|
}
|
||||||
param_complexity: usize,
|
|
||||||
composite_score: f64,
|
#[derive(Debug, Clone, Serialize)]
|
||||||
line_count: usize,
|
pub struct SignatureRow {
|
||||||
param_count: usize,
|
pub function: String,
|
||||||
|
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 FileComplexityJson {
|
struct BranchingJson {
|
||||||
path: String,
|
|
||||||
function_count: usize,
|
|
||||||
total_complexity: f64,
|
|
||||||
mean_complexity: f64,
|
|
||||||
max_complexity: f64,
|
|
||||||
total_generics: usize,
|
|
||||||
trait_impl_count: usize,
|
|
||||||
derive_density: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ComplexityStatsJson {
|
|
||||||
total_functions: usize,
|
|
||||||
mean: f64,
|
|
||||||
std_dev: f64,
|
|
||||||
median: f64,
|
|
||||||
max: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct ComplexityJson {
|
|
||||||
cstat_version: String,
|
cstat_version: String,
|
||||||
functions: Vec<FunctionComplexityJson>,
|
functions: Vec<BranchingRow>,
|
||||||
files: Vec<FileComplexityJson>,
|
}
|
||||||
stats: Option<ComplexityStatsJson>,
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SignatureJson {
|
||||||
|
cstat_version: String,
|
||||||
|
functions: Vec<SignatureRow>,
|
||||||
|
files: Vec<SignatureFileRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SpanJson {
|
||||||
|
cstat_version: String,
|
||||||
|
functions: Vec<SpanRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute complexity metrics for a single function.
|
/// Compute complexity metrics for a single function.
|
||||||
|
|
@ -110,11 +107,7 @@ 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 = if func.line_end >= func.line_start {
|
let line_count = function_line_count(func);
|
||||||
func.line_end - func.line_start + 1
|
|
||||||
} else {
|
|
||||||
func.body_stmt_count.max(1)
|
|
||||||
};
|
|
||||||
|
|
||||||
FunctionComplexity {
|
FunctionComplexity {
|
||||||
name: func.name.clone(),
|
name: func.name.clone(),
|
||||||
|
|
@ -122,8 +115,6 @@ 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,
|
||||||
|
|
@ -376,86 +367,6 @@ 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> {
|
||||||
|
|
@ -495,283 +406,380 @@ fn strip_project_path(path: &str, project_path: &Path) -> String {
|
||||||
stripped.to_string()
|
stripped.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the function complexity rankings table.
|
fn relative_file(path: &Path, project_path: &Path) -> String {
|
||||||
fn render_function_rankings(
|
strip_project_path(&path.display().to_string(), project_path)
|
||||||
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()
|
|
||||||
);
|
|
||||||
|
|
||||||
if verbose {
|
fn function_line_count(func: &FunctionInfo) -> usize {
|
||||||
render::verbose_block(&[
|
if func.line_end >= func.line_start {
|
||||||
"Functions ranked by composite complexity score (highest = most complex).",
|
func.line_end - func.line_start + 1
|
||||||
"Each column is an individual complexity metric:",
|
|
||||||
" Cycl = cyclomatic complexity = number of linearly independent paths",
|
|
||||||
" 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 = func_complexities.len().min(20);
|
|
||||||
println!(
|
|
||||||
"{} functions analyzed, showing top {}\n",
|
|
||||||
format!("{}", func_complexities.len()).bold(),
|
|
||||||
format!("{}", display_count).bold(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Header
|
|
||||||
println!(
|
|
||||||
" {:<40} {:>5} {:>5} {:>5} {:>5} {:>5} {:>7}",
|
|
||||||
"Function".bold().underline(),
|
|
||||||
"Cycl".bold().underline(),
|
|
||||||
"Cogn".bold().underline(),
|
|
||||||
"Nest".bold().underline(),
|
|
||||||
"Gen".bold().underline(),
|
|
||||||
"Param".bold().underline(),
|
|
||||||
"Score".bold().underline(),
|
|
||||||
);
|
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
fc.name.clone()
|
func.body_stmt_count.max(1)
|
||||||
};
|
|
||||||
|
|
||||||
let score_str = format!("{:.1}", fc.composite_score);
|
|
||||||
let colored_score = render::bar_color(&score_str, ratio);
|
|
||||||
|
|
||||||
println!(
|
|
||||||
" {:<40} {:>5} {:>5} {:>5} {:>5} {:>5} {}",
|
|
||||||
name_display.dimmed(),
|
|
||||||
fc.cyclomatic,
|
|
||||||
fc.cognitive,
|
|
||||||
fc.nesting_depth,
|
|
||||||
fc.generic_complexity,
|
|
||||||
fc.param_complexity,
|
|
||||||
colored_score,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if func_complexities.len() > display_count {
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
format!(
|
|
||||||
" ... and {} more functions",
|
|
||||||
func_complexities.len() - display_count
|
|
||||||
)
|
|
||||||
.dimmed()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the per-file complexity bar chart.
|
fn signature_score(func: &FunctionInfo) -> usize {
|
||||||
fn render_file_complexity_chart(
|
func.param_count
|
||||||
file_complexities: &[FileComplexity],
|
+ func.return_type_complexity
|
||||||
term_w: usize,
|
+ func.generic_param_count
|
||||||
verbose: bool,
|
+ func.trait_bound_count
|
||||||
) {
|
+ func.where_predicate_count
|
||||||
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()
|
|
||||||
);
|
|
||||||
|
|
||||||
if verbose {
|
fn truncate(value: &str, max: usize) -> String {
|
||||||
render::verbose_block(&[
|
if value.chars().count() <= max {
|
||||||
"Total composite complexity score per file (sum of all function scores in the file).",
|
return value.to_string();
|
||||||
"Bar length is proportional to the file with the highest total score.",
|
|
||||||
"Color: red = top of ranking, green = bottom. Files sorted descending.",
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_display_count = file_complexities.len().min(20);
|
let keep = max.saturating_sub(3);
|
||||||
let display_files = &file_complexities[..file_display_count];
|
let mut out = value.chars().take(keep).collect::<String>();
|
||||||
|
out.push_str("...");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
let max_label_len = display_files
|
pub fn collect_branching(symbols: &ProjectSymbols, project_path: &Path) -> Vec<BranchingRow> {
|
||||||
|
let mut rows: Vec<BranchingRow> = symbols
|
||||||
|
.files
|
||||||
.iter()
|
.iter()
|
||||||
.map(|f| f.path.len())
|
.flat_map(|file| file.functions.iter())
|
||||||
.max()
|
.map(|func| BranchingRow {
|
||||||
.unwrap_or(0)
|
function: func.name.clone(),
|
||||||
.min(40);
|
file: relative_file(&func.file, project_path),
|
||||||
|
cyclomatic: func.branch_points + 1,
|
||||||
|
branch_points: func.branch_points,
|
||||||
|
nesting_depth: func.nesting_depth,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let max_score = display_files
|
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()
|
.iter()
|
||||||
.map(|f| f.total_complexity)
|
.flat_map(|file| file.functions.iter())
|
||||||
.fold(0.0f64, f64::max);
|
.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();
|
||||||
|
|
||||||
let score_digits = format!("{:.1}", max_score).len();
|
rows.sort_by(|a, b| {
|
||||||
let bar_budget = term_w
|
b.signature_score
|
||||||
.saturating_sub(max_label_len)
|
.cmp(&a.signature_score)
|
||||||
.saturating_sub(3) // " \u{2502} "
|
.then_with(|| b.param_count.cmp(&a.param_count))
|
||||||
.saturating_sub(score_digits)
|
.then_with(|| b.return_type_complexity.cmp(&a.return_type_complexity))
|
||||||
.saturating_sub(2)
|
.then_with(|| b.generic_param_count.cmp(&a.generic_param_count))
|
||||||
.max(10);
|
.then_with(|| b.trait_bound_count.cmp(&a.trait_bound_count))
|
||||||
|
.then_with(|| b.where_predicate_count.cmp(&a.where_predicate_count))
|
||||||
println!();
|
.then_with(|| a.file.cmp(&b.file))
|
||||||
for (i, fc) in display_files.iter().enumerate() {
|
.then_with(|| a.function.cmp(&b.function))
|
||||||
let ratio = i as f64 / file_display_count.max(1) as f64;
|
});
|
||||||
let label = if fc.path.len() > 40 {
|
rows
|
||||||
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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the aggregate complexity statistics box.
|
pub fn collect_signature_files(
|
||||||
fn render_complexity_stats(stats: &ComplexityStats, verbose: bool) {
|
symbols: &ProjectSymbols,
|
||||||
println!(
|
project_path: &Path,
|
||||||
"\n{}",
|
) -> Vec<SignatureFileRow> {
|
||||||
"\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}"
|
let mut rows: Vec<SignatureFileRow> = symbols
|
||||||
.bright_cyan()
|
.files
|
||||||
.bold()
|
.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();
|
||||||
|
|
||||||
if verbose {
|
rows.sort_by(|a, b| {
|
||||||
render::verbose_block(&[
|
b.trait_impl_count
|
||||||
"Aggregate statistics for composite complexity across all functions.",
|
.cmp(&a.trait_impl_count)
|
||||||
"Mean = average score. Std Dev = spread. Median = middle value (robust to outliers).",
|
.then_with(|| a.file.cmp(&b.file))
|
||||||
"Max = highest individual function score in the project.",
|
});
|
||||||
]);
|
rows
|
||||||
}
|
|
||||||
|
|
||||||
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}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the complexity subcommand output.
|
pub fn collect_spans(symbols: &ProjectSymbols, project_path: &Path) -> Vec<SpanRow> {
|
||||||
pub fn render_complexity(symbols: &ProjectSymbols, project_path: &Path, verbose: bool) {
|
let mut rows: Vec<SpanRow> = symbols
|
||||||
let func_complexities = compute_all(symbols, project_path);
|
.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();
|
||||||
|
|
||||||
if func_complexities.is_empty() {
|
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());
|
println!("{}", "No functions found to analyze.".yellow());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_complexities = compute_file_complexity(symbols, &func_complexities, project_path);
|
render::section_header("Function Branching Complexity");
|
||||||
let stats = compute_stats(&func_complexities);
|
if verbose {
|
||||||
let term_w = render::terminal_width();
|
render::verbose_block(&[
|
||||||
|
"Functions ranked by decision/path complexity.",
|
||||||
render_function_rankings(&func_complexities, term_w, verbose);
|
"Cyclomatic complexity is branch_points + 1.",
|
||||||
render_file_complexity_chart(&file_complexities, term_w, verbose);
|
"Branch points and nesting depth come from the parsed control-flow visitor.",
|
||||||
|
"cognitive and composite_score are intentionally not part of this view.",
|
||||||
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} {:>8} {:>5}",
|
||||||
|
"Function".bold().underline(),
|
||||||
|
"File".bold().underline(),
|
||||||
|
"Cycl".bold().underline(),
|
||||||
|
"Branches".bold().underline(),
|
||||||
|
"Nest".bold().underline(),
|
||||||
|
);
|
||||||
|
|
||||||
|
for row in rows.iter().take(display_count) {
|
||||||
|
println!(
|
||||||
|
" {:<40} {:<30} {:>5} {:>8} {:>5}",
|
||||||
|
truncate(&row.function, 40).dimmed(),
|
||||||
|
truncate(&row.file, 30).dimmed(),
|
||||||
|
row.cyclomatic,
|
||||||
|
row.branch_points,
|
||||||
|
row.nesting_depth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.len() > display_count {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(" ... and {} more functions", rows.len() - display_count).dimmed()
|
||||||
|
);
|
||||||
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render complexity analysis as JSON.
|
pub fn render_branching_json(symbols: &ProjectSymbols, project_path: &Path) {
|
||||||
pub fn render_complexity_json(symbols: &ProjectSymbols, project_path: &Path) {
|
let output = BranchingJson {
|
||||||
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,
|
functions: collect_branching(symbols, project_path),
|
||||||
files,
|
|
||||||
stats: stats_json,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("{}", serde_json::to_string(&output).unwrap());
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(rs_files: &[std::path::PathBuf], project_path: &Path, json: bool, verbose: bool) {
|
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 {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Functions ranked by API-boundary complexity.",
|
||||||
|
"Signature score = params + return type complexity + generic params + trait bounds + where predicates.",
|
||||||
|
"This view answers call/satisfy/reason-about difficulty, not control-flow or implementation span.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.is_empty() {
|
||||||
|
println!("{}", "No functions found to analyze.".yellow());
|
||||||
|
} 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) {
|
||||||
|
println!(
|
||||||
|
" {:<34} {:<24} {:>6} {:>6} {:>8} {:>6} {:>5} {:>5}",
|
||||||
|
truncate(&row.function, 34).dimmed(),
|
||||||
|
truncate(&row.file, 24).dimmed(),
|
||||||
|
row.param_count,
|
||||||
|
row.return_type_complexity,
|
||||||
|
row.generic_param_count,
|
||||||
|
row.trait_bound_count,
|
||||||
|
row.where_predicate_count,
|
||||||
|
row.signature_score,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.len() > display_count {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(" ... and {} more functions", rows.len() - display_count).dimmed()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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!();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_signature_json(symbols: &ProjectSymbols, project_path: &Path) {
|
||||||
|
let output = SignatureJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
functions: collect_signatures(symbols, project_path),
|
||||||
|
files: collect_signature_files(symbols, project_path),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_span(symbols: &ProjectSymbols, project_path: &Path, verbose: bool) {
|
||||||
|
let rows = collect_spans(symbols, project_path);
|
||||||
|
if rows.is_empty() {
|
||||||
|
println!("{}", "No functions found to analyze.".yellow());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
render::section_header("Function Implementation Span");
|
||||||
|
if verbose {
|
||||||
|
render::verbose_block(&[
|
||||||
|
"Functions ranked by implementation-unit size.",
|
||||||
|
"line_count is the inclusive function-local line_start..=line_end span.",
|
||||||
|
"This does not replace cstat loc, which remains file/project LOC.",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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!();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_span_json(symbols: &ProjectSymbols, project_path: &Path) {
|
||||||
|
let output = SpanJson {
|
||||||
|
cstat_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
functions: collect_spans(symbols, project_path),
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{}", serde_json::to_string(&output).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_branching(rs_files: &[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_complexity_json(&symbols, project_path);
|
render_branching_json(&symbols, project_path);
|
||||||
} else {
|
} else {
|
||||||
render_complexity(&symbols, project_path, verbose);
|
render_branching(&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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
src/dump.rs
12
src/dump.rs
|
|
@ -180,15 +180,11 @@ 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"],
|
||||||
"bloated_function",
|
"cstat branching --json",
|
||||||
"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"),
|
||||||
(
|
(
|
||||||
|
|
|
||||||
25
src/guide.rs
25
src/guide.rs
|
|
@ -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/complexity/deps/flow/graph as needed.",
|
dimensions with loc/branching/signature/span/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,22 +96,23 @@ 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 quantify different aspects of how difficult code is to \
|
Complexity metrics are split by question: branching for decision/path shape, \
|
||||||
understand, test, and modify. No single metric captures the full picture — \
|
signature for API-boundary difficulty, and span for function-local implementation \
|
||||||
cyclomatic counts paths, cognitive models reading difficulty, nesting measures \
|
size. No single metric captures the full picture — cyclomatic counts paths, \
|
||||||
|
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. Models how hard the code is for a human to read."),
|
("cognitive complexity", "SonarSource model: +1 per control flow break, +nesting_level penalty. Used by diagnostics/distributions, not exposed as a top-level probe."),
|
||||||
("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."),
|
||||||
("composite score", "Weighted combination: 1.0×cyclomatic + 0.5×cognitive + 0.3×nesting + 0.2×generic + 0.1×param. Useful for ranking."),
|
("function span", "Inclusive function-local line range and top-level body statement count. This complements file/project LOC."),
|
||||||
],
|
],
|
||||||
commands: &[
|
commands: &[
|
||||||
"cstat complexity — per-function and per-file complexity rankings",
|
"cstat branching — decision/path complexity by function",
|
||||||
"cstat complexity --json — structured complexity data for all functions",
|
"cstat signature — API-boundary complexity by function and file",
|
||||||
"cstat dist --metric cyclomatic — distribution and outlier analysis",
|
"cstat span — function-local implementation size and line ranges",
|
||||||
"cstat dist --metric cognitive — cognitive complexity distribution",
|
"cstat advanced dist --metric cyclomatic — distribution and outlier analysis",
|
||||||
],
|
],
|
||||||
patterns: &[
|
patterns: &[
|
||||||
"High cyclomatic but low cognitive complexity usually means straightforward \
|
"High cyclomatic but low cognitive complexity usually means straightforward \
|
||||||
|
|
@ -119,8 +120,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.",
|
||||||
"The composite score is useful for triage: sort by composite to find functions \
|
"Use branching, signature, and span together: a function can be easy to call \
|
||||||
that are complex along multiple dimensions simultaneously.",
|
but branch-heavy, short but hard to satisfy generically, or long without many decisions.",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
src/main.rs
28
src/main.rs
|
|
@ -93,8 +93,12 @@ 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,
|
||||||
/// Per-function and per-file complexity rankings
|
/// Function decision/path complexity rankings
|
||||||
Complexity,
|
Branching,
|
||||||
|
/// 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
|
||||||
|
|
@ -242,8 +246,14 @@ 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::Complexity => {
|
Commands::Branching => {
|
||||||
complexity::run(target_rs_files, project_path, json, verbose);
|
complexity::run_branching(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 {
|
||||||
|
|
@ -354,8 +364,14 @@ fn main() {
|
||||||
symbols::render_symbols(&rs_files, &project_path, verbose);
|
symbols::render_symbols(&rs_files, &project_path, verbose);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Commands::Complexity => {
|
Commands::Branching => {
|
||||||
complexity::run(&rs_files, &project_path, json, verbose);
|
complexity::run_branching(&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);
|
||||||
|
|
|
||||||
|
|
@ -133,11 +133,6 @@ 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());
|
||||||
|
|
|
||||||
|
|
@ -193,51 +193,53 @@ fn render_loc_bars(file_stats: &[loc::FileLocStats], project_path_canon: &Path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the complexity hotspots table.
|
/// Render the branching 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("Complexity Hotspots");
|
render::section_header("Branching Hotspots");
|
||||||
|
|
||||||
if verbose {
|
if verbose {
|
||||||
render::verbose_block(&[
|
render::verbose_block(&[
|
||||||
"Functions ranked by composite complexity score (highest first).",
|
"Functions ranked by cyclomatic branching complexity (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).",
|
||||||
"Cogn = cognitive complexity (SonarSource model): +1 per control flow break, +nesting penalty.",
|
"Branches = cyclomatic - 1. Nest = deepest nesting depth within the function body.",
|
||||||
"Nest = deepest nesting depth within the function body.",
|
"See 'cstat branching', 'cstat signature', and 'cstat span' for focused detail.",
|
||||||
"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 display_count = func_complexities.len().min(10);
|
let mut rows: Vec<_> = func_complexities.iter().collect();
|
||||||
|
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} {:>5} {:>5} {:>7}",
|
" {:<36} {:>5} {:>8} {:>5}",
|
||||||
"Function".bold().underline(),
|
"Function".bold().underline(),
|
||||||
"Cycl".bold().underline(),
|
"Cycl".bold().underline(),
|
||||||
"Cogn".bold().underline(),
|
"Branches".bold().underline(),
|
||||||
"Nest".bold().underline(),
|
"Nest".bold().underline(),
|
||||||
"Score".bold().underline(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
for (i, fc) in func_complexities.iter().take(display_count).enumerate() {
|
for fc in rows.into_iter().take(display_count) {
|
||||||
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} {:>5} {:>5} {}",
|
" {:<36} {:>5} {:>8} {:>5}",
|
||||||
name.dimmed(),
|
name.dimmed(),
|
||||||
fc.cyclomatic,
|
fc.cyclomatic,
|
||||||
fc.cognitive,
|
fc.cyclomatic.saturating_sub(1),
|
||||||
fc.nesting_depth,
|
fc.nesting_depth,
|
||||||
colored_score,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if func_complexities.len() > display_count {
|
if func_complexities.len() > display_count {
|
||||||
|
|
|
||||||
283
tests/function_probes_cli.rs
Normal file
283
tests/function_probes_cli.rs
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue