pretender 0.6.0

Multi-language structural code-quality checks — complexity, duplication, mutation testing, and CI integration
use crate::model::{Block, BranchKind, CodeUnit, Metric, Node};
use std::collections::HashSet;

pub struct CyclomaticComplexity;

impl Metric for CyclomaticComplexity {
    fn calculate(&self, unit: &CodeUnit) -> u32 {
        cyclomatic(unit)
    }
}

pub fn cyclomatic(unit: &CodeUnit) -> u32 {
    1 + count_branches(&unit.body)
}

pub fn cognitive(unit: &CodeUnit) -> u32 {
    let mut seen_logical_sequences = HashSet::new();
    cognitive_block(&unit.body, &mut seen_logical_sequences)
}

pub fn function_lines(unit: &CodeUnit) -> u32 {
    unit.span.lines()
}

pub fn params(unit: &CodeUnit) -> u32 {
    unit.parameters.len() as u32
}

pub fn nesting_max(unit: &CodeUnit) -> u32 {
    nesting_max_block(&unit.body)
}

pub fn abc(unit: &CodeUnit) -> f64 {
    let totals = abc_block(&unit.body);
    (totals.assignments * totals.assignments
        + totals.branches * totals.branches
        + totals.calls * totals.calls)
        .sqrt()
}

fn count_branches(block: &Block) -> u32 {
    block
        .children
        .iter()
        .map(|n| match n {
            Node::Branch(_) => 1,
            Node::NestedBlock(b) => count_branches(b),
            _ => 0,
        })
        .sum()
}

fn cognitive_block(
    block: &Block,
    seen_logical_sequences: &mut HashSet<(BranchKind, u32, u32)>,
) -> u32 {
    block
        .children
        .iter()
        .map(|node| match node {
            Node::Branch(branch)
                if should_count_cognitive_branch(
                    branch.kind,
                    branch.sequence_id,
                    branch.nesting_at,
                    seen_logical_sequences,
                ) =>
            {
                branch.cognitive_weight * (1 + branch.nesting_at)
            }
            Node::Branch(_) => 0,
            Node::NestedBlock(block) => cognitive_block(block, seen_logical_sequences),
            _ => 0,
        })
        .sum()
}

fn should_count_cognitive_branch(
    kind: BranchKind,
    sequence_id: Option<u32>,
    nesting_at: u32,
    seen_logical_sequences: &mut HashSet<(BranchKind, u32, u32)>,
) -> bool {
    match (kind, sequence_id) {
        (BranchKind::LogicalAnd | BranchKind::LogicalOr, Some(id)) => {
            seen_logical_sequences.insert((kind, id, nesting_at))
        }
        _ => true,
    }
}

fn nesting_max_block(block: &Block) -> u32 {
    block
        .children
        .iter()
        .filter_map(|node| match node {
            Node::NestedBlock(child) => Some(nesting_max_block(child)),
            _ => None,
        })
        .max()
        .map_or(block.nesting, |child_max| block.nesting.max(child_max))
}

#[derive(Debug, Default)]
struct AbcTotals {
    assignments: f64,
    branches: f64,
    calls: f64,
}

fn abc_block(block: &Block) -> AbcTotals {
    block
        .children
        .iter()
        .fold(AbcTotals::default(), |mut totals, node| {
            match node {
                Node::Assignment(_) => totals.assignments += 1.0,
                Node::Branch(_) => totals.branches += 1.0,
                Node::Call(call) => totals.calls += call.smell_weight,
                Node::NestedBlock(child) => {
                    let child_totals = abc_block(child);
                    totals.assignments += child_totals.assignments;
                    totals.branches += child_totals.branches;
                    totals.calls += child_totals.calls;
                }
                _ => {}
            }
            totals
        })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{Branch, BranchKind, CallSite, Parameter, Span, UnitKind};

    fn span(start_line: u32, end_line: u32) -> Span {
        Span {
            start_line,
            end_line,
        }
    }

    fn branch(kind: BranchKind, nesting_at: u32, sequence_id: Option<u32>) -> Node {
        weighted_branch(kind, nesting_at, sequence_id, 1, 1)
    }

    fn weighted_branch(
        kind: BranchKind,
        nesting_at: u32,
        sequence_id: Option<u32>,
        cyclomatic_weight: u32,
        cognitive_weight: u32,
    ) -> Node {
        Node::Branch(Branch {
            kind,
            span: span(1, 1),
            nesting_at,
            sequence_id,
            cyclomatic_weight,
            cognitive_weight,
        })
    }

    fn unit(body: Block) -> CodeUnit {
        CodeUnit {
            name: "example".to_string(),
            kind: UnitKind::Function,
            span: span(10, 14),
            parameters: vec![
                Parameter {
                    name: "a".to_string(),
                    span: span(10, 10),
                    type_name: None,
                },
                Parameter {
                    name: "b".to_string(),
                    span: span(10, 10),
                    type_name: None,
                },
            ],
            body,
            parent_class: None,
            has_test_attribute: false,
            is_exported: false,
            assertions: 0,
        }
    }

    #[test]
    fn calculates_basic_count_metrics_recursively() {
        let code_unit = unit(Block {
            span: span(11, 13),
            nesting: 0,
            children: vec![
                branch(BranchKind::If, 0, None),
                Node::NestedBlock(Block {
                    span: span(12, 13),
                    nesting: 1,
                    children: vec![branch(BranchKind::Loop, 1, None)],
                }),
            ],
        });

        assert_eq!(cyclomatic(&code_unit), 3);
        assert_eq!(function_lines(&code_unit), 5);
        assert_eq!(params(&code_unit), 2);
        assert_eq!(nesting_max(&code_unit), 1);
    }

    #[test]
    fn cognitive_counts_logical_sequences_once_and_weights_nesting() {
        let code_unit = unit(Block {
            span: span(1, 5),
            nesting: 0,
            children: vec![
                branch(BranchKind::If, 0, None),
                branch(BranchKind::LogicalAnd, 0, Some(1)),
                branch(BranchKind::LogicalAnd, 0, Some(1)),
                branch(BranchKind::LogicalOr, 0, Some(2)),
                Node::NestedBlock(Block {
                    span: span(2, 4),
                    nesting: 1,
                    children: vec![branch(BranchKind::Loop, 1, None)],
                }),
            ],
        });

        assert_eq!(cognitive(&code_unit), 5);
    }

    #[test]
    fn cognitive_uses_branch_specific_weights() {
        let code_unit = unit(Block {
            span: span(1, 3),
            nesting: 0,
            children: vec![
                weighted_branch(BranchKind::If, 0, None, 1, 3),
                Node::NestedBlock(Block {
                    span: span(2, 3),
                    nesting: 1,
                    children: vec![weighted_branch(BranchKind::Loop, 1, None, 1, 2)],
                }),
            ],
        });

        assert_eq!(cognitive(&code_unit), 7);
    }

    #[test]
    fn empty_unit_has_baseline_metric_values() {
        let code_unit = unit(Block {
            span: span(1, 1),
            nesting: 0,
            children: Vec::new(),
        });

        assert_eq!(cyclomatic(&code_unit), 1);
        assert_eq!(cognitive(&code_unit), 0);
        assert_eq!(nesting_max(&code_unit), 0);
        assert_eq!(abc(&code_unit), 0.0);
    }

    #[test]
    fn abc_uses_assignments_branches_and_weighted_calls() {
        let code_unit = unit(Block {
            span: span(1, 3),
            nesting: 0,
            children: vec![
                Node::Assignment(span(1, 1)),
                Node::Assignment(span(2, 2)),
                branch(BranchKind::If, 0, None),
                Node::Call(CallSite {
                    callee: "expensive".to_string(),
                    span: span(3, 3),
                    smell_weight: 1.5,
                }),
                Node::NestedBlock(Block {
                    span: span(3, 3),
                    nesting: 1,
                    children: vec![Node::Call(CallSite {
                        callee: "normal".to_string(),
                        span: span(3, 3),
                        smell_weight: 1.0,
                    })],
                }),
            ],
        });

        let score = abc(&code_unit);

        assert!((score - (2.0_f64 * 2.0 + 1.0 + 2.5_f64 * 2.5).sqrt()).abs() < 0.000_001);
    }
}