use std::cmp::Reverse;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::core::config::Config;
use crate::core::finding::{Finding, IntoFindings, Location};
use crate::core::severity::Severity;
use crate::feature::{decorate, Feature, FeatureKind, FeatureMeta, HotspotIndex};
use crate::observer::code::complexity::{analyze, FunctionMetric, ParsedFile};
use crate::observer::shared::lang::Language;
use crate::observer::{impl_workspace_builder, ObservationMeta, Observer};
use crate::observers::ObserverReports;
impl_workspace_builder!(ComplexityObserver);
#[derive(Debug, Clone, Default)]
pub struct ComplexityObserver {
pub excluded: Vec<String>,
pub ccn_enabled: bool,
pub cognitive_enabled: bool,
pub workspace: Option<PathBuf>,
}
impl ComplexityObserver {
#[must_use]
pub fn from_config(cfg: &Config) -> Self {
Self {
excluded: cfg.exclude_lines(),
ccn_enabled: cfg.metrics.is_enabled("ccn"),
cognitive_enabled: cfg.metrics.is_enabled("cognitive"),
workspace: None,
}
}
#[must_use]
pub fn scan(&self, root: &Path) -> ComplexityReport {
let Some(mut acc) = self.accumulator() else {
return ComplexityReport::default();
};
crate::observer::code::scan_source_tree(
root,
&self.excluded,
self.workspace.as_deref(),
Some(&mut acc),
None,
None,
);
acc.finish()
}
pub(crate) fn accumulator(&self) -> Option<ComplexityAccumulator> {
(self.ccn_enabled || self.cognitive_enabled).then(ComplexityAccumulator::default)
}
}
#[derive(Default)]
pub(crate) struct ComplexityAccumulator {
files: Vec<FileComplexity>,
totals: ComplexityTotals,
}
impl ComplexityAccumulator {
pub(crate) fn add(&mut self, rel: &Path, lang: Language, parsed: &ParsedFile) {
let metrics = analyze(parsed);
if metrics.is_empty() {
return;
}
for fun in &metrics {
self.totals.functions += 1;
self.totals.max_ccn = self.totals.max_ccn.max(fun.ccn);
self.totals.max_cognitive = self.totals.max_cognitive.max(fun.cognitive);
}
self.files.push(FileComplexity {
path: rel.to_path_buf(),
language: lang.name().to_string(),
functions: metrics,
});
}
pub(crate) fn finish(mut self) -> ComplexityReport {
self.totals.files = self.files.len();
self.files.sort_by(|a, b| a.path.cmp(&b.path));
ComplexityReport {
files: self.files,
totals: self.totals,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ComplexityReport {
pub files: Vec<FileComplexity>,
pub totals: ComplexityTotals,
}
impl ComplexityReport {
#[must_use]
pub fn worst_n(&self, n: usize, metric: ComplexityMetric) -> Vec<FunctionFinding> {
let mut all: Vec<FunctionFinding> = self
.files
.iter()
.flat_map(|file| {
file.functions.iter().map(|fun| FunctionFinding {
file: file.path.clone(),
language: file.language.clone(),
name: fun.name.clone(),
line: fun.start_line,
ccn: fun.ccn,
cognitive: fun.cognitive,
})
})
.collect();
let key = |f: &FunctionFinding| match metric {
ComplexityMetric::Ccn => f.ccn,
ComplexityMetric::Cognitive => f.cognitive,
};
all.sort_by(|a, b| {
Reverse(key(a))
.cmp(&Reverse(key(b)))
.then_with(|| a.file.cmp(&b.file))
.then_with(|| a.name.cmp(&b.name))
});
all.truncate(n);
all
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileComplexity {
pub path: PathBuf,
pub language: String,
pub functions: Vec<FunctionMetric>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ComplexityTotals {
pub files: usize,
pub functions: usize,
pub max_ccn: u32,
pub max_cognitive: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FunctionFinding {
pub file: PathBuf,
pub language: String,
pub name: String,
pub line: u32,
pub ccn: u32,
pub cognitive: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComplexityMetric {
Ccn,
Cognitive,
}
impl Observer for ComplexityObserver {
type Output = ComplexityReport;
fn meta(&self) -> ObservationMeta {
ObservationMeta {
name: "complexity",
version: 1,
}
}
fn observe(&self, project_root: &Path) -> anyhow::Result<Self::Output> {
Ok(self.scan(project_root))
}
}
fn function_seed_suffixes(functions: &[FunctionMetric]) -> Vec<String> {
let mut seen: std::collections::HashMap<(&str, u32), u32> = std::collections::HashMap::new();
functions
.iter()
.map(|fun| {
let span = fun.end_line.saturating_sub(fun.start_line);
let ordinal = seen
.entry((fun.name.as_str(), span))
.and_modify(|n| *n += 1)
.or_insert(0);
if *ordinal == 0 {
format!("{span}")
} else {
format!("{span}:{ordinal}")
}
})
.collect()
}
impl IntoFindings for ComplexityReport {
fn into_findings(&self) -> Vec<Finding> {
let mut out = Vec::with_capacity(self.totals.functions);
for file in &self.files {
let seeds = function_seed_suffixes(&file.functions);
for (fun, seed) in file.functions.iter().zip(&seeds) {
let location = Location {
file: file.path.clone(),
line: Some(fun.start_line),
symbol: Some(fun.name.clone()),
};
if fun.ccn > 0 {
out.push(Finding::new(
"ccn",
location.clone(),
format!("CCN={} {} ({})", fun.ccn, fun.name, file.language),
&format!("ccn:{seed}"),
));
}
if fun.cognitive > 0 {
out.push(Finding::new(
"cognitive",
location,
format!(
"Cognitive={} {} ({})",
fun.cognitive, fun.name, file.language
),
&format!("cognitive:{seed}"),
));
}
}
}
out
}
}
pub struct ComplexityFeature;
impl Feature for ComplexityFeature {
fn meta(&self) -> FeatureMeta {
FeatureMeta {
name: "complexity",
version: 1,
kind: FeatureKind::Observer,
}
}
fn enabled(&self, cfg: &Config) -> bool {
cfg.metrics.is_enabled("ccn") || cfg.metrics.is_enabled("cognitive")
}
fn lower(
&self,
reports: &ObserverReports,
cfg: &Config,
cal: &crate::core::calibration::Calibration,
hotspot: &HotspotIndex,
) -> Vec<Finding> {
let workspaces = cfg.project.workspaces.as_slice();
let mut out = Vec::new();
for file in &reports.complexity.files {
let metrics = cal.metrics_for_file(&file.path, workspaces);
let cal_ccn = metrics.ccn.as_ref();
let cal_cog = metrics.cognitive.as_ref();
let seeds = function_seed_suffixes(&file.functions);
for (fun, seed) in file.functions.iter().zip(&seeds) {
let location = Location {
file: file.path.clone(),
line: Some(fun.start_line),
symbol: Some(fun.name.clone()),
};
if cfg.metrics.is_enabled("ccn") && fun.ccn > 0 {
let f = Finding::new(
"ccn",
location.clone(),
format!("CCN={} {} ({})", fun.ccn, fun.name, file.language),
&format!("ccn:{seed}"),
);
let sev = cal_ccn.map_or(Severity::Ok, |c| c.classify(f64::from(fun.ccn)));
out.push(decorate(f, sev, hotspot));
}
if cfg.metrics.is_enabled("cognitive") && fun.cognitive > 0 {
let f = Finding::new(
"cognitive",
location,
format!(
"Cognitive={} {} ({})",
fun.cognitive, fun.name, file.language
),
&format!("cognitive:{seed}"),
);
let sev =
cal_cog.map_or(Severity::Ok, |c| c.classify(f64::from(fun.cognitive)));
out.push(decorate(f, sev, hotspot));
}
}
}
out
}
}