rscheck-cli 0.1.0-alpha.3

CLI frontend for the rscheck policy engine.
Documentation
use crate::analysis::Workspace;
use crate::config::{ComplexityMode, FileComplexityConfig};
use crate::emit::Emitter;
use crate::report::{FileMetrics, Finding};
use crate::rules::{Rule, RuleBackend, RuleContext, RuleFamily, RuleInfo};
use crate::span::{Location, Span};
use std::path::Path;
use syn::visit::Visit;

pub struct FileComplexityRule;

impl FileComplexityRule {
    pub fn static_info() -> RuleInfo {
        RuleInfo {
            id: "shape.file_complexity",
            family: RuleFamily::Shape,
            backend: RuleBackend::Syntax,
            summary: "Measures file and function complexity against configured limits.",
            default_level: FileComplexityConfig::default().level,
            schema: "level, mode, max_file, max_fn, count_question, match_arms",
            config_example: "[rules.\"shape.file_complexity\"]\nlevel = \"warn\"\nmode = \"cyclomatic\"\nmax_file = 200\nmax_fn = 25",
            fixable: false,
        }
    }
}

impl Rule for FileComplexityRule {
    fn info(&self) -> RuleInfo {
        Self::static_info()
    }

    fn run(&self, ws: &Workspace, ctx: &RuleContext<'_>, out: &mut dyn Emitter) {
        for file in &ws.files {
            let cfg = match ctx
                .policy
                .decode_rule::<FileComplexityConfig>(Self::static_info().id, Some(&file.path))
            {
                Ok(cfg) => cfg,
                Err(_) => continue,
            };
            let Some(ast) = &file.ast else { continue };

            match cfg.mode {
                ComplexityMode::Cyclomatic => {
                    let mut v = CyclomaticVisitor {
                        count_question: cfg.count_question,
                        match_arms: cfg.match_arms,
                        per_fn: Vec::new(),
                    };
                    v.visit_file(ast);

                    let sum = v.per_fn.iter().map(|c| c.score).sum::<u32>();
                    let max_fn = v.per_fn.iter().map(|c| c.score).max().unwrap_or(0);

                    out.record_metrics(FileMetrics {
                        path: file.path.to_string_lossy().to_string(),
                        cyclomatic_sum: sum,
                        cyclomatic_max_fn: max_fn,
                    });

                    let over_file = sum > cfg.max_file;
                    let over_fn = max_fn > cfg.max_fn;
                    if over_file || over_fn {
                        let mut msg = String::new();
                        if over_file {
                            msg.push_str(&format!(
                                "file cyclomatic complexity sum {sum} exceeds {}\n",
                                cfg.max_file
                            ));
                        }
                        if over_fn {
                            msg.push_str(&format!(
                                "max function cyclomatic complexity {max_fn} exceeds {}\n",
                                cfg.max_fn
                            ));
                        }
                        out.emit(Finding {
                            rule_id: Self::static_info().id.to_string(),
                            family: Some(Self::static_info().family),
                            engine: Some(Self::static_info().backend),
                            severity: cfg.level.to_severity(),
                            message: msg.trim_end().to_string(),
                            primary: Some(file_span(&file.path)),
                            secondary: Vec::new(),
                            help: Some(
                                "Split the functions or module to reduce branching.".to_string(),
                            ),
                            evidence: Some(format_per_fn(&v.per_fn)),
                            confidence: None,
                            tags: vec!["complexity".to_string()],
                            labels: Vec::new(),
                            notes: Vec::new(),
                            fixes: Vec::new(),
                        });
                    }
                }
                ComplexityMode::PhysicalLoc => {
                    let loc = count_physical_loc(&file.text);
                    if loc as u32 > cfg.max_file {
                        out.emit(Finding {
                            rule_id: Self::static_info().id.to_string(),
                            family: Some(Self::static_info().family),
                            engine: Some(Self::static_info().backend),
                            severity: cfg.level.to_severity(),
                            message: format!("file physical LOC {loc} exceeds {}", cfg.max_file),
                            primary: Some(file_span(&file.path)),
                            secondary: Vec::new(),
                            help: Some("Split the file into smaller modules.".to_string()),
                            evidence: None,
                            confidence: None,
                            tags: vec!["size".to_string()],
                            labels: Vec::new(),
                            notes: Vec::new(),
                            fixes: Vec::new(),
                        });
                    }
                }
                ComplexityMode::LogicalLoc => {
                    let mut v = LogicalLocVisitor { stmts: 0 };
                    v.visit_file(ast);
                    let ll = v.stmts;
                    if ll > cfg.max_file {
                        out.emit(Finding {
                            rule_id: Self::static_info().id.to_string(),
                            family: Some(Self::static_info().family),
                            engine: Some(Self::static_info().backend),
                            severity: cfg.level.to_severity(),
                            message: format!("file logical LOC {ll} exceeds {}", cfg.max_file),
                            primary: Some(file_span(&file.path)),
                            secondary: Vec::new(),
                            help: Some("Split the file into smaller modules.".to_string()),
                            evidence: None,
                            confidence: None,
                            tags: vec!["size".to_string()],
                            labels: Vec::new(),
                            notes: Vec::new(),
                            fixes: Vec::new(),
                        });
                    }
                }
            }
        }
    }
}

fn file_span(path: &Path) -> Span {
    Span::new(
        path,
        Location { line: 1, column: 1 },
        Location { line: 1, column: 1 },
    )
}

fn format_per_fn(per_fn: &[FnScore]) -> String {
    let mut out = String::new();
    for s in per_fn {
        out.push_str(&format!("{}: {}\n", s.name, s.score));
    }
    out
}

fn count_physical_loc(text: &str) -> usize {
    text.lines()
        .filter_map(|line| {
            let t = line.trim();
            if t.is_empty() {
                return None;
            }
            if t.starts_with("//") {
                return None;
            }
            Some(())
        })
        .count()
}

#[derive(Debug, Clone)]
struct FnScore {
    name: String,
    score: u32,
}

struct CyclomaticVisitor {
    count_question: bool,
    match_arms: bool,
    per_fn: Vec<FnScore>,
}

impl CyclomaticVisitor {
    fn bump(&mut self, n: u32) {
        if let Some(last) = self.per_fn.last_mut() {
            last.score = last.score.saturating_add(n);
        }
    }
}

impl<'ast> Visit<'ast> for CyclomaticVisitor {
    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
        let name = node.sig.ident.to_string();
        self.per_fn.push(FnScore { name, score: 1 });
        syn::visit::visit_item_fn(self, node);
    }

    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
        let name = node.sig.ident.to_string();
        self.per_fn.push(FnScore { name, score: 1 });
        syn::visit::visit_impl_item_fn(self, node);
    }

    fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
        self.bump(1);
        syn::visit::visit_expr_if(self, node);
    }

    fn visit_expr_for_loop(&mut self, node: &'ast syn::ExprForLoop) {
        self.bump(1);
        syn::visit::visit_expr_for_loop(self, node);
    }

    fn visit_expr_while(&mut self, node: &'ast syn::ExprWhile) {
        self.bump(1);
        syn::visit::visit_expr_while(self, node);
    }

    fn visit_expr_loop(&mut self, node: &'ast syn::ExprLoop) {
        self.bump(1);
        syn::visit::visit_expr_loop(self, node);
    }

    fn visit_expr_match(&mut self, node: &'ast syn::ExprMatch) {
        if self.match_arms {
            self.bump(node.arms.len() as u32);
        } else {
            self.bump(1);
        }
        syn::visit::visit_expr_match(self, node);
    }

    fn visit_expr_binary(&mut self, node: &'ast syn::ExprBinary) {
        if matches!(node.op, syn::BinOp::And(_) | syn::BinOp::Or(_)) {
            self.bump(1);
        }
        syn::visit::visit_expr_binary(self, node);
    }

    fn visit_expr_try(&mut self, node: &'ast syn::ExprTry) {
        if self.count_question {
            self.bump(1);
        }
        syn::visit::visit_expr_try(self, node);
    }
}

struct LogicalLocVisitor {
    stmts: u32,
}

impl<'ast> Visit<'ast> for LogicalLocVisitor {
    fn visit_stmt(&mut self, node: &'ast syn::Stmt) {
        self.stmts = self.stmts.saturating_add(1);
        syn::visit::visit_stmt(self, node);
    }
}

#[cfg(test)]
mod tests;