diff --git a/src/commands.rs b/src/commands.rs index 94bc165..d3bb111 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -16,7 +16,9 @@ use crate::{circuits, complexity, datapaths, deps, flow, graph, loc, redundancy} pub enum Command { Summary, Loc { top: Option }, - Complexity, + Branching, + Signature, + Span, Deps { top: Option }, Map, 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(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, || deps::render_deps_json(rs_files, &p), || deps::render_deps(rs_files, &p, top, verbose), diff --git a/src/complexity.rs b/src/complexity.rs index b66c6cf..31dceeb 100644 --- a/src/complexity.rs +++ b/src/complexity.rs @@ -1,6 +1,6 @@ use colored::Colorize; use serde::Serialize; -use std::path::Path; +use std::path::{Path, PathBuf}; use syn::visit::Visit; use syn::Expr; @@ -15,27 +15,11 @@ pub struct FunctionComplexity { pub cyclomatic: usize, pub cognitive: usize, pub nesting_depth: usize, - pub generic_complexity: usize, - pub param_complexity: usize, pub composite_score: f64, pub line_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. pub struct ComplexityStats { pub total_functions: usize, @@ -45,47 +29,60 @@ pub struct ComplexityStats { pub max: f64, } -#[derive(Serialize)] -struct FunctionComplexityJson { - name: String, - file: String, - cyclomatic: usize, - cognitive: usize, - nesting_depth: usize, - generic_complexity: usize, - param_complexity: usize, - composite_score: f64, - line_count: usize, - param_count: usize, +#[derive(Debug, Clone, Serialize)] +pub struct BranchingRow { + pub function: String, + pub file: String, + pub cyclomatic: usize, + pub branch_points: usize, + pub nesting_depth: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SignatureRow { + 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)] -struct FileComplexityJson { - 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 { +struct BranchingJson { cstat_version: String, - functions: Vec, - files: Vec, - stats: Option, + functions: Vec, +} + +#[derive(Serialize)] +struct SignatureJson { + cstat_version: String, + functions: Vec, + files: Vec, +} + +#[derive(Serialize)] +struct SpanJson { + cstat_version: String, + functions: Vec, } /// 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 + param_complexity as f64 * 0.1; - let line_count = if func.line_end >= func.line_start { - func.line_end - func.line_start + 1 - } else { - func.body_stmt_count.max(1) - }; + let line_count = function_line_count(func); FunctionComplexity { name: func.name.clone(), @@ -122,8 +115,6 @@ fn compute_function_complexity(func: &FunctionInfo, source: &str) -> FunctionCom cyclomatic, cognitive, nesting_depth: func.nesting_depth, - generic_complexity, - param_complexity, composite_score, line_count, param_count: func.param_count, @@ -376,86 +367,6 @@ pub fn compute_all(symbols: &ProjectSymbols, project_path: &Path) -> Vec Vec { - let mut file_map: std::collections::HashMap> = - std::collections::HashMap::new(); - - for fc in func_complexities { - file_map.entry(fc.file.clone()).or_default().push(fc); - } - - let mut file_complexities: Vec = 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 = 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::() - + file_syms - .enums - .iter() - .map(|e| e.generic_param_count) - .sum::(); - 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::() - + file_syms - .enums - .iter() - .map(|e| e.derive_count) - .sum::(); - 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. pub fn compute_stats(complexities: &[FunctionComplexity]) -> Option { @@ -495,283 +406,380 @@ fn strip_project_path(path: &str, project_path: &Path) -> String { stripped.to_string() } -/// Render the function complexity rankings table. -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 relative_file(path: &Path, project_path: &Path) -> String { + strip_project_path(&path.display().to_string(), project_path) +} - if verbose { - render::verbose_block(&[ - "Functions ranked by composite complexity score (highest = most complex).", - "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 { - fc.name.clone() - }; - - 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() - ); +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) } } -/// Render the per-file complexity bar chart. -fn render_file_complexity_chart( - file_complexities: &[FileComplexity], - 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() - ); +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 +} - if verbose { - render::verbose_block(&[ - "Total composite complexity score per file (sum of all function scores in the file).", - "Bar length is proportional to the file with the highest total score.", - "Color: red = top of ranking, green = bottom. Files sorted descending.", - ]); +fn truncate(value: &str, max: usize) -> String { + if value.chars().count() <= max { + return value.to_string(); } - let file_display_count = file_complexities.len().min(20); - let display_files = &file_complexities[..file_display_count]; + let keep = max.saturating_sub(3); + let mut out = value.chars().take(keep).collect::(); + out.push_str("..."); + out +} - let max_label_len = display_files +pub fn collect_branching(symbols: &ProjectSymbols, project_path: &Path) -> Vec { + let mut rows: Vec = symbols + .files .iter() - .map(|f| f.path.len()) - .max() - .unwrap_or(0) - .min(40); + .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(); - 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 { + let mut rows: Vec = symbols + .files .iter() - .map(|f| f.total_complexity) - .fold(0.0f64, f64::max); + .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(); - 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); - - 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() - ); - } + 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 } -/// Render the aggregate complexity statistics box. -fn render_complexity_stats(stats: &ComplexityStats, verbose: bool) { - println!( - "\n{}", - "\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() - ); +pub fn collect_signature_files( + symbols: &ProjectSymbols, + project_path: &Path, +) -> Vec { + let mut rows: Vec = 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(); - 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}"); + rows.sort_by(|a, b| { + b.trait_impl_count + .cmp(&a.trait_impl_count) + .then_with(|| a.file.cmp(&b.file)) + }); + rows } -/// Render the complexity subcommand output. -pub fn render_complexity(symbols: &ProjectSymbols, project_path: &Path, verbose: bool) { - let func_complexities = compute_all(symbols, project_path); +pub fn collect_spans(symbols: &ProjectSymbols, project_path: &Path) -> Vec { + let mut rows: Vec = 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(); - 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()); return; } - let file_complexities = compute_file_complexity(symbols, &func_complexities, project_path); - let stats = compute_stats(&func_complexities); - let term_w = render::terminal_width(); - - render_function_rankings(&func_complexities, term_w, verbose); - render_file_complexity_chart(&file_complexities, term_w, verbose); - - if let Some(ref stats) = stats { - render_complexity_stats(stats, verbose); + render::section_header("Function Branching Complexity"); + if verbose { + render::verbose_block(&[ + "Functions ranked by decision/path complexity.", + "Cyclomatic complexity is branch_points + 1.", + "Branch points and nesting depth come from the parsed control-flow visitor.", + "cognitive and composite_score are intentionally not part of this view.", + ]); } + 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!(); } -/// Render complexity analysis as JSON. -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 = 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 = 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 { +pub fn render_branching_json(symbols: &ProjectSymbols, project_path: &Path) { + let output = BranchingJson { cstat_version: env!("CARGO_PKG_VERSION").to_string(), - functions, - files, - stats: stats_json, + functions: collect_branching(symbols, project_path), }; 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); if json { - render_complexity_json(&symbols, project_path); + render_branching_json(&symbols, project_path); } 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); } } diff --git a/src/dump.rs b/src/dump.rs index bccccae..1d76395 100644 --- a/src/dump.rs +++ b/src/dump.rs @@ -180,15 +180,11 @@ fn build_drilldowns(diagnostics: &[diagnostics::Diagnostic]) -> Vec { // Map categories to subcommands let category_to_command: &[(&[&str], &str)] = &[ ( - &[ - "bloated_function", - "high_complexity", - "high_cognitive", - "deep_nesting", - "too_many_params", - ], - "cstat complexity --json", + &["high_complexity", "high_cognitive", "deep_nesting"], + "cstat branching --json", ), + (&["too_many_params"], "cstat signature --json"), + (&["bloated_function"], "cstat span --json"), (&["redundant_code"], "cstat redundancy --json"), (&["bloated_file"], "cstat loc --json"), ( diff --git a/src/guide.rs b/src/guide.rs index 91cbdc9..8b6d3ec 100644 --- a/src/guide.rs +++ b/src/guide.rs @@ -52,7 +52,7 @@ or `cstat dump` for a machine-readable JSON diagnostic report.", ], patterns: &[ "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 \ identify weak dimensions, use individual commands with --json to explore specifics.", "The -v (verbose) flag adds contextual explanations to any command — useful \ @@ -95,22 +95,23 @@ signal is when a file is large AND has low cohesion or high internal complexity. fn topic_complexity() -> TopicContent { TopicContent { description: "\ -Complexity metrics quantify different aspects of how difficult code is to \ -understand, test, and modify. No single metric captures the full picture — \ -cyclomatic counts paths, cognitive models reading difficulty, nesting measures \ +Complexity metrics are split by question: branching for decision/path shape, \ +signature for API-boundary difficulty, and span for function-local implementation \ +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.", metrics: &[ ("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."), ("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: &[ - "cstat complexity — per-function and per-file complexity rankings", - "cstat complexity --json — structured complexity data for all functions", - "cstat dist --metric cyclomatic — distribution and outlier analysis", - "cstat dist --metric cognitive — cognitive complexity distribution", + "cstat branching — decision/path complexity by function", + "cstat signature — API-boundary complexity by function and file", + "cstat span — function-local implementation size and line ranges", + "cstat advanced dist --metric cyclomatic — distribution and outlier analysis", ], patterns: &[ "High cyclomatic but low cognitive complexity usually means straightforward \ @@ -118,8 +119,8 @@ branching (e.g. match statements with simple arms). High cognitive with \ moderate cyclomatic often means nested conditionals.", "Functions with nesting depth > 4 are nearly always worth refactoring — \ extract inner blocks into helper functions to flatten the structure.", - "The composite score is useful for triage: sort by composite to find functions \ -that are complex along multiple dimensions simultaneously.", + "Use branching, signature, and span together: a function can be easy to call \ +but branch-heavy, short but hard to satisfy generically, or long without many decisions.", ], } } diff --git a/src/main.rs b/src/main.rs index 63b6733..99b3bfc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,8 +64,12 @@ enum Commands { }, /// Symbol counts by kind and file Symbols, - /// Per-function and per-file complexity rankings - Complexity, + /// Function decision/path complexity rankings + Branching, + /// Function API boundary complexity rankings + Signature, + /// Function implementation span rankings + Span, /// Module dependency connectome Deps { /// Show only the top N most connected modules @@ -209,8 +213,14 @@ fn main() { symbols::render_symbols_file(project_rs_files, project_path, file, verbose); } } - Commands::Complexity => { - complexity::run(target_rs_files, project_path, json, verbose); + Commands::Branching => { + 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 => { if json { @@ -321,8 +331,14 @@ fn main() { symbols::render_symbols(&rs_files, &project_path, verbose); } } - Commands::Complexity => { - complexity::run(&rs_files, &project_path, json, verbose); + Commands::Branching => { + 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 => { cluster::render_cluster(&rs_files, &project_path, json, verbose); diff --git a/src/render.rs b/src/render.rs index 6071eef..e88b591 100644 --- a/src/render.rs +++ b/src/render.rs @@ -133,11 +133,6 @@ pub fn print_stat_row(label: &str, value: &str) { 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. pub fn verbose_line(text: &str) { println!(" {}", format!("ℹ {}", text).dimmed()); diff --git a/src/summary/mod.rs b/src/summary/mod.rs index 2840c40..c0a0aff 100644 --- a/src/summary/mod.rs +++ b/src/summary/mod.rs @@ -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) { - render::section_header("Complexity Hotspots"); + render::section_header("Branching Hotspots"); if verbose { render::verbose_block(&[ - "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.", + "Functions ranked by cyclomatic branching complexity (highest first).", "Cycl = cyclomatic complexity = branch points + 1 (linearly independent paths).", - "Cogn = cognitive complexity (SonarSource model): +1 per control flow break, +nesting penalty.", - "Nest = deepest nesting depth within the function body.", - "Score column: red = highest complexity, green = lowest. See 'cstat complexity' for full detail.", + "Branches = cyclomatic - 1. Nest = deepest nesting depth within the function body.", + "See 'cstat branching', 'cstat signature', and 'cstat span' for focused detail.", ]); } if func_complexities.is_empty() { println!(" {}", "No functions found.".dimmed()); } 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!( - " {:<36} {:>5} {:>5} {:>5} {:>7}", + " {:<36} {:>5} {:>8} {:>5}", "Function".bold().underline(), "Cycl".bold().underline(), - "Cogn".bold().underline(), + "Branches".bold().underline(), "Nest".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; + for fc in rows.into_iter().take(display_count) { let name = if fc.name.len() > 34 { format!("{}...", &fc.name[..31]) } else { fc.name.clone() }; - let score_str = format!("{:.1}", fc.composite_score); - let colored_score = render::bar_color(&score_str, ratio); println!( - " {:<36} {:>5} {:>5} {:>5} {}", + " {:<36} {:>5} {:>8} {:>5}", name.dimmed(), fc.cyclomatic, - fc.cognitive, + fc.cyclomatic.saturating_sub(1), fc.nesting_depth, - colored_score, ); } if func_complexities.len() > display_count { diff --git a/tests/function_probes_cli.rs b/tests/function_probes_cli.rs new file mode 100644 index 0000000..9213a5c --- /dev/null +++ b/tests/function_probes_cli.rs @@ -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 { + value: T, +} + +impl Worker for Engine +where + T: Copy + Into, +{ + 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( + first: T, + second: U, + count: usize, +) -> Result, String> +where + T: Clone + Into, + 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(); +}