use colored::Colorize; use serde::Serialize; use std::path::Path; use crate::complexity::{self, FunctionComplexity}; use crate::render; // ── Metric extraction ────────────────────────────────────────────── const METRIC_NAMES: &[&str] = &["loc", "cyclomatic", "cognitive", "nesting", "params"]; struct MetricSet { /// One vector of values per metric, in METRIC_NAMES order. columns: Vec>, } fn extract_metrics(fcs: &[FunctionComplexity]) -> MetricSet { let mut columns: Vec> = vec![Vec::new(); METRIC_NAMES.len()]; for fc in fcs { columns[0].push(fc.line_count as f64); columns[1].push(fc.cyclomatic as f64); columns[2].push(fc.cognitive as f64); columns[3].push(fc.nesting_depth as f64); columns[4].push(fc.param_count as f64); } MetricSet { columns } } // ── Statistics helpers ───────────────────────────────────────────── fn mean(v: &[f64]) -> f64 { if v.is_empty() { return 0.0; } v.iter().sum::() / v.len() as f64 } fn std_dev(v: &[f64]) -> f64 { if v.len() < 2 { return 0.0; } let m = mean(v); let var = v.iter().map(|x| (x - m) * (x - m)).sum::() / v.len() as f64; var.sqrt() } fn pearson(x: &[f64], y: &[f64]) -> f64 { let n = x.len(); if n < 2 { return 0.0; } let mx = mean(x); let my = mean(y); let mut num = 0.0; let mut dx2 = 0.0; let mut dy2 = 0.0; for i in 0..n { let dx = x[i] - mx; let dy = y[i] - my; num += dx * dy; dx2 += dx * dx; dy2 += dy * dy; } let denom = (dx2 * dy2).sqrt(); if denom < 1e-12 { 0.0 } else { num / denom } } fn skewness(v: &[f64]) -> f64 { let n = v.len(); if n < 3 { return 0.0; } let m = mean(v); let s = std_dev(v); if s < 1e-12 { return 0.0; } let m3 = v.iter().map(|x| ((x - m) / s).powi(3)).sum::(); m3 / n as f64 } fn kurtosis(v: &[f64]) -> f64 { let n = v.len(); if n < 4 { return 0.0; } let m = mean(v); let s = std_dev(v); if s < 1e-12 { return 0.0; } let m4 = v.iter().map(|x| ((x - m) / s).powi(4)).sum::(); m4 / n as f64 - 3.0 // excess kurtosis } // ── Histogram ────────────────────────────────────────────────────── fn render_histogram(values: &[f64], label: &str, num_bins: usize, verbose: bool) { if values.is_empty() { return; } let min_v = values.iter().cloned().fold(f64::INFINITY, f64::min); let max_v = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); // Cap bins to the actual range for integer-valued data let range = max_v - min_v; let num_bins = if range.abs() < 1e-12 { 1 } else { let int_range = range.ceil() as usize; num_bins.min(int_range.max(1)) }; let bin_width = if num_bins == 1 { 1.0 } else { range / num_bins as f64 }; let mut bins = vec![0usize; num_bins]; for &v in values { let idx = if num_bins == 1 { 0 } else { ((v - min_v) / bin_width).floor() as usize }; let idx = idx.min(num_bins - 1); bins[idx] += 1; } let max_count = *bins.iter().max().unwrap_or(&1).max(&1); let term_w = render::terminal_width(); let label_w = 12; // " [xxx, yyy)" let count_w = format!("{}", max_count).len() + 1; let bar_budget = term_w.saturating_sub(label_w + count_w + 4).max(10); println!( "\n {} (n={})", label.bright_cyan().bold(), format!("{}", values.len()).bold() ); if verbose { render::verbose_block(&[ "Histogram: frequency distribution of values. Each row is a bin range.", "Bar length proportional to count. Color: red = most frequent, green = least.", "Stats below: skew > 0 = right-tailed (few very high values),", " excess kurtosis > 0 = heavy tails (more outliers than a normal distribution).", ]); render::guide_ref("complexity"); } for i in 0..num_bins { let lo = min_v + i as f64 * bin_width; let hi = lo + bin_width; let range_label = if num_bins == 1 { format!("[{:.0}]", lo) } else if i == num_bins - 1 { format!("[{:.0},{:.0}]", lo, hi) } else { format!("[{:.0},{:.0})", lo, hi) }; let bar_len = if max_count > 0 { (bins[i] as f64 / max_count as f64 * bar_budget as f64).ceil() as usize } else { 0 }.max(if bins[i] > 0 { 1 } else { 0 }); let ratio = if max_count > 0 { 1.0 - (bins[i] as f64 / max_count as f64) } else { 1.0 }; let bar = render::bar_color(&"█".repeat(bar_len), ratio); println!( " {:>10} │ {}{} {}", range_label.dimmed(), bar, " ".repeat(bar_budget.saturating_sub(bar_len)), format!("{}", bins[i]).bold(), ); } // Distribution shape stats let m = mean(values); let sd = std_dev(values); let sk = skewness(values); let ku = kurtosis(values); println!( " {} mean={:.1} σ={:.1} skew={:.2} kurt={:.2}", "↳".dimmed(), m, sd, sk, ku ); } // ── Scatter plot ─────────────────────────────────────────────────── fn render_scatter(x: &[f64], y: &[f64], x_label: &str, y_label: &str, verbose: bool) { if x.is_empty() { return; } let r = pearson(x, y); println!( "\n{}", format!("── {} vs {} (r={:.3}) ", x_label, y_label, r) .bright_cyan() .bold() ); if verbose { render::verbose_block(&[ "Scatter plot: each ● represents one or more functions at that (x, y) position.", " Green ● = 1 function, Yellow ● = 2-3, Red ● = 4+ (overlapping).", " · = empty cell. X-axis = LoC, Y-axis = composite complexity.", "Pearson r measures linear correlation: r > 0.7 = strong positive,", " r ≈ 0 = no linear relationship, r < -0.7 = strong negative.", ]); } let plot_w: usize = render::terminal_width().min(72).saturating_sub(8); let plot_h: usize = 20; let x_min = x.iter().cloned().fold(f64::INFINITY, f64::min); let x_max = x.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let y_min = y.iter().cloned().fold(f64::INFINITY, f64::min); let y_max = y.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let x_range = if (x_max - x_min).abs() < 1e-12 { 1.0 } else { x_max - x_min }; let y_range = if (y_max - y_min).abs() < 1e-12 { 1.0 } else { y_max - y_min }; // Build grid with counts let mut grid = vec![vec![0u32; plot_w]; plot_h]; for i in 0..x.len() { let col = ((x[i] - x_min) / x_range * (plot_w - 1) as f64).round() as usize; let row = ((y[i] - y_min) / y_range * (plot_h - 1) as f64).round() as usize; let col = col.min(plot_w - 1); let row = row.min(plot_h - 1); grid[row][col] += 1; } // Render top to bottom (high y first) let y_label_w = 6; for row in (0..plot_h).rev() { let y_val = y_min + (row as f64 / (plot_h - 1).max(1) as f64) * y_range; let label = if row == plot_h - 1 || row == 0 || row == plot_h / 2 { format!("{:>5.0}", y_val) } else { " ".to_string() }; let mut line = String::new(); for col in 0..plot_w { let count = grid[row][col]; if count == 0 { line.push('·'); } else if count == 1 { line.push_str(&"●".green().to_string()); } else if count < 4 { line.push_str(&"●".yellow().to_string()); } else { line.push_str(&"●".red().bold().to_string()); } } let border = if row == 0 { "└" } else { "│" }; println!("{} {}{}", label.dimmed(), border, line); } // X axis let x_min_s = format!("{:.0}", x_min); let x_max_s = format!("{:.0}", x_max); let mid_x = (x_min + x_max) / 2.0; let x_mid_s = format!("{:.0}", mid_x); let axis_padding = plot_w.saturating_sub(x_min_s.len() + x_max_s.len() + x_mid_s.len()) / 2; println!( "{} {}{}{}{}{}", " ".repeat(y_label_w), x_min_s.dimmed(), " ".repeat(axis_padding), x_mid_s.dimmed(), " ".repeat(axis_padding), x_max_s.dimmed(), ); println!( "{} {} → {} {} ↑", " ".repeat(y_label_w), x_label.dimmed(), y_label.dimmed(), format!("r={:.3}", r).bold(), ); } // ── Outlier detection ────────────────────────────────────────────── fn render_outliers(fcs: &[FunctionComplexity], metrics: &MetricSet, verbose: bool) { println!( "\n{}", "── Outliers (z-score > 2.0) ───────────────────────────" .bright_cyan() .bold() ); if verbose { render::verbose_block(&[ "Outlier detection: functions with a z-score > 2.0 in any metric.", "z-score = (value - mean) / standard deviation. z > 2.0 means the value is", "more than 2 standard deviations above the mean — statistically unusual.", "These are candidates for refactoring or closer inspection.", "Grouped by metric, showing up to 5 outliers per metric.", ]); } let mut any_outlier = false; for (mi, metric_name) in METRIC_NAMES.iter().enumerate() { let vals = &metrics.columns[mi]; let m = mean(vals); let s = std_dev(vals); if s < 1e-12 { continue; } let mut outliers: Vec<(usize, f64)> = Vec::new(); for (i, &v) in vals.iter().enumerate() { let z = (v - m) / s; if z > 2.0 { outliers.push((i, z)); } } outliers.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); if !outliers.is_empty() { any_outlier = true; println!("\n {} ({} outlier{})", metric_name.yellow().bold(), outliers.len(), if outliers.len() == 1 { "" } else { "s" }); for (i, z) in outliers.iter().take(5) { let name = if fcs[*i].name.len() > 45 { format!("{}...", &fcs[*i].name[..42]) } else { fcs[*i].name.clone() }; println!( " {} {} (z={:.2}, val={:.0})", "▸".red(), name.dimmed(), z, vals[*i], ); } } } if !any_outlier { println!(" {}", "No outliers detected.".dimmed()); } } // ── Correlation matrix ───────────────────────────────────────────── fn render_correlation_matrix(metrics: &MetricSet, verbose: bool) { let n = METRIC_NAMES.len(); println!( "\n{}", "── Correlation Matrix ─────────────────────────────────" .bright_cyan() .bold() ); if verbose { render::verbose_block(&[ "Pairwise Pearson correlation coefficients (r) between all metrics.", "r ranges from -1.0 (perfect negative) to +1.0 (perfect positive).", "Color: red/bold = strong positive (r > 0.7), blue/bold = strong negative (r < -0.7),", " yellow = moderate positive (r > 0.4), cyan = moderate negative (r < -0.4),", " dim = weak correlation (|r| ≤ 0.4).", "Diagonal is always 1.000 (a metric perfectly correlates with itself).", "High correlation between two metrics suggests redundancy or a shared underlying factor.", ]); } // Header row print!(" {:>12}", ""); for name in METRIC_NAMES { print!(" {:>10}", name.bold()); } println!(); for i in 0..n { print!(" {:>12}", METRIC_NAMES[i].bold()); for j in 0..n { let r = pearson(&metrics.columns[i], &metrics.columns[j]); let cell = format!("{:>7.3}", r); let colored = color_correlation(&cell, r); print!(" {}", colored); } println!(); } } fn color_correlation(text: &str, r: f64) -> String { let abs_r = r.abs(); if abs_r > 0.7 { if r > 0.0 { text.red().bold().to_string() } else { text.blue().bold().to_string() } } else if abs_r > 0.4 { if r > 0.0 { text.yellow().to_string() } else { text.cyan().to_string() } } else { text.dimmed().to_string() } } // ── Public entry point ───────────────────────────────────────────── pub fn render_dist( rs_files: &[std::path::PathBuf], project_path: &Path, metric_filter: Option<&str>, num_bins: usize, verbose: bool, ) { let symbols = crate::ast_parser::parse_project(rs_files); let fcs = complexity::compute_all(&symbols, project_path); if fcs.is_empty() { println!("{}", "No functions found to analyze.".yellow()); return; } let metrics = extract_metrics(&fcs); println!( "\n{}", "── Distribution Analysis ──────────────────────────────" .bright_cyan() .bold() ); if verbose { render::verbose_block(&[ "Statistical distribution analysis of complexity metrics across all functions.", "Histograms show frequency distributions. Scatter plot shows LoC vs complexity.", "Outliers are functions with z-score > 2.0 (more than 2σ above the mean).", "Correlation matrix shows pairwise Pearson r between all metrics.", ]); } println!( "{} functions across {} files\n", format!("{}", fcs.len()).bold(), format!("{}", rs_files.len()).bold(), ); // Histograms let labels = &["Function LoC", "Cyclomatic", "Cognitive", "Nesting Depth", "Param Count"]; if let Some(filter) = metric_filter { // Find the matching metric let filter_lower = filter.to_lowercase(); if let Some(idx) = METRIC_NAMES.iter().position(|&n| n == filter_lower) { render_histogram(&metrics.columns[idx], labels[idx], num_bins, verbose); } else { println!( "{} Unknown metric '{}'. Available: {}", "Error:".red().bold(), filter, METRIC_NAMES.join(", "), ); return; } } else { for (i, label) in labels.iter().enumerate() { render_histogram(&metrics.columns[i], label, num_bins, verbose); } } // Scatter plot: complexity (composite score) vs LoC let composite_scores: Vec = fcs.iter().map(|f| f.composite_score).collect(); render_scatter( &metrics.columns[0], // LoC &composite_scores, "Function LoC", "Complexity", verbose, ); // Outlier detection render_outliers(&fcs, &metrics, verbose); // Correlation matrix render_correlation_matrix(&metrics, verbose); println!(); } #[derive(Serialize)] struct HistogramBinJson { low: f64, high: f64, count: usize, } #[derive(Serialize)] struct HistogramJson { metric: String, bins: Vec, mean: f64, std_dev: f64, skewness: f64, kurtosis: f64, } #[derive(Serialize)] struct OutlierJson { metric: String, function: String, z_score: f64, value: f64, } #[derive(Serialize)] struct CorrelationEntryJson { metric_a: String, metric_b: String, pearson_r: f64, } #[derive(Serialize)] struct DistJson { cstat_version: String, histograms: Vec, outliers: Vec, correlations: Vec, } fn compute_histogram_bins(values: &[f64], num_bins: usize) -> Vec { if values.is_empty() { return vec![]; } let min_v = values.iter().cloned().fold(f64::INFINITY, f64::min); let max_v = values.iter().cloned().fold(f64::NEG_INFINITY, f64::max); let range = max_v - min_v; let num_bins = if range.abs() < 1e-12 { 1 } else { let int_range = range.ceil() as usize; num_bins.min(int_range.max(1)) }; let bin_width = if num_bins == 1 { 1.0 } else { range / num_bins as f64 }; let mut counts = vec![0usize; num_bins]; for &v in values { let idx = if num_bins == 1 { 0 } else { ((v - min_v) / bin_width).floor() as usize }; let idx = idx.min(num_bins - 1); counts[idx] += 1; } (0..num_bins).map(|i| { let lo = min_v + i as f64 * bin_width; let hi = lo + bin_width; HistogramBinJson { low: lo, high: hi, count: counts[i] } }).collect() } /// Render distribution analysis as JSON. pub fn render_dist_json( rs_files: &[std::path::PathBuf], project_path: &std::path::Path, metric_filter: Option<&str>, num_bins: usize, ) { let symbols = crate::ast_parser::parse_project(rs_files); let fcs = complexity::compute_all(&symbols, project_path); let metrics = extract_metrics(&fcs); let labels = &["loc", "cyclomatic", "cognitive", "nesting", "params"]; let indices: Vec = if let Some(filter) = metric_filter { let filter_lower = filter.to_lowercase(); if let Some(idx) = METRIC_NAMES.iter().position(|&n| n == filter_lower) { vec![idx] } else { vec![] } } else { (0..METRIC_NAMES.len()).collect() }; let histograms: Vec = indices.iter().map(|&i| { let vals = &metrics.columns[i]; let bins_json = compute_histogram_bins(vals, num_bins); HistogramJson { metric: labels[i].to_string(), bins: bins_json, mean: mean(vals), std_dev: std_dev(vals), skewness: skewness(vals), kurtosis: kurtosis(vals), } }).collect(); let mut outliers_json: Vec = Vec::new(); for (mi, metric_name) in METRIC_NAMES.iter().enumerate() { let vals = &metrics.columns[mi]; let m = mean(vals); let s = std_dev(vals); if s < 1e-12 { continue; } for (i, &v) in vals.iter().enumerate() { let z = (v - m) / s; if z > 2.0 { outliers_json.push(OutlierJson { metric: metric_name.to_string(), function: fcs[i].name.clone(), z_score: z, value: v, }); } } } outliers_json.sort_by(|a, b| b.z_score.partial_cmp(&a.z_score).unwrap_or(std::cmp::Ordering::Equal)); let n = METRIC_NAMES.len(); let mut correlations: Vec = Vec::new(); for i in 0..n { for j in (i+1)..n { let r = pearson(&metrics.columns[i], &metrics.columns[j]); correlations.push(CorrelationEntryJson { metric_a: METRIC_NAMES[i].to_string(), metric_b: METRIC_NAMES[j].to_string(), pearson_r: r, }); } } let output = DistJson { cstat_version: env!("CARGO_PKG_VERSION").to_string(), histograms, outliers: outliers_json, correlations, }; println!("{}", serde_json::to_string(&output).unwrap()); }