use std::path::Path;
use cccc_core::engine;
use cccc_core::ir::{LogicalOp, Node, SwitchCase};
use cccc_core::report::FileReport;
use syn::spanned::Spanned;
use syn::visit::{self, Visit};
use syn::{
BinOp, Expr, ExprBinary, ExprBreak, ExprCall, ExprClosure, ExprContinue, ExprForLoop, ExprIf,
ExprLoop, ExprMatch, ExprMethodCall, ExprWhile, ImplItemFn, ItemFn, Local, Pat, TraitItemFn,
};
pub const DEFAULT_EXTS: &[&str] = &["rs"];
pub fn analyze_source(path: &Path, source: &str) -> FileReport {
let (nodes, parse_errors) = to_ir(path, source);
engine::analyze(&path.display().to_string(), &nodes, parse_errors)
}
pub fn to_ir(_path: &Path, source: &str) -> (Vec<Node>, Vec<String>) {
match syn::parse_file(source) {
Ok(file) => {
let mut builder = Builder::new();
for item in &file.items {
builder.visit_item(item);
}
(builder.finish(), Vec::new())
}
Err(e) => (Vec::new(), vec![e.to_string()]),
}
}
struct Builder {
stack: Vec<Vec<Node>>,
pending_name: Option<String>,
}
impl Builder {
fn new() -> Self {
Self {
stack: vec![Vec::new()], pending_name: None,
}
}
fn finish(mut self) -> Vec<Node> {
self.stack.pop().expect("module collector")
}
fn emit(&mut self, node: Node) {
self.stack.last_mut().expect("collector").push(node);
}
fn collect<F: FnOnce(&mut Self)>(&mut self, f: F) -> Vec<Node> {
self.stack.push(Vec::new());
f(self);
self.stack.pop().expect("collector")
}
fn emit_function<F: FnOnce(&mut Self)>(
&mut self,
name: String,
kind: &'static str,
line: u32,
walk: F,
) {
let body = self.collect(walk);
self.emit(Node::Function {
name,
kind: kind.to_string(),
line,
body,
});
}
fn lower_if(&mut self, it: &ExprIf) -> Node {
let test = self.collect(|s| s.visit_expr(&it.cond));
let then = self.collect(|s| s.visit_block(&it.then_branch));
let alternate = it
.else_branch
.as_ref()
.map(|(_, alt)| Box::new(self.lower_alternate(alt)));
Node::Branch {
test,
then,
alternate,
}
}
fn lower_alternate(&mut self, expr: &Expr) -> Node {
match expr {
Expr::If(elif) => self.lower_if(elif),
other => Node::Group(self.collect(|s| s.visit_expr(other))),
}
}
fn collect_logical(&mut self, expr: &ExprBinary, op: LogicalOp, operands: &mut Vec<Node>) {
self.collect_logical_side(&expr.left, op, operands);
self.collect_logical_side(&expr.right, op, operands);
}
fn collect_logical_side(&mut self, side: &Expr, op: LogicalOp, operands: &mut Vec<Node>) {
match side {
Expr::Binary(inner) => match logical_op(&inner.op) {
Some(inner_op) if inner_op == op => self.collect_logical(inner, op, operands),
Some(inner_op) => {
let mut sub = Vec::new();
self.collect_logical(inner, inner_op, &mut sub);
operands.push(Node::Logical {
op: inner_op,
operands: sub,
});
}
None => operands.push(Node::Group(self.collect(|s| s.visit_expr(side)))),
},
Expr::Paren(p) => self.collect_logical_side(&p.expr, op, operands),
Expr::Group(g) => self.collect_logical_side(&g.expr, op, operands),
_ => operands.push(Node::Group(self.collect(|s| s.visit_expr(side)))),
}
}
}
impl<'ast> Visit<'ast> for Builder {
fn visit_item_fn(&mut self, it: &'ast ItemFn) {
let name = it.sig.ident.to_string();
let line = line_of(&it.sig.ident);
self.emit_function(name, "function", line, |s| visit::visit_item_fn(s, it));
}
fn visit_impl_item_fn(&mut self, it: &'ast ImplItemFn) {
let name = it.sig.ident.to_string();
let line = line_of(&it.sig.ident);
self.emit_function(name, "method", line, |s| visit::visit_impl_item_fn(s, it));
}
fn visit_trait_item_fn(&mut self, it: &'ast TraitItemFn) {
if it.default.is_some() {
let name = it.sig.ident.to_string();
let line = line_of(&it.sig.ident);
self.emit_function(name, "method", line, |s| visit::visit_trait_item_fn(s, it));
}
}
fn visit_local(&mut self, it: &'ast Local) {
if let Some(init) = &it.init
&& matches!(&*init.expr, Expr::Closure(_))
{
self.pending_name = pat_name(&it.pat);
}
visit::visit_local(self, it);
}
fn visit_expr_closure(&mut self, it: &'ast ExprClosure) {
let name = self
.pending_name
.take()
.unwrap_or_else(|| "<closure>".to_string());
let line = line_of(it);
self.emit_function(name, "closure", line, |s| visit::visit_expr_closure(s, it));
}
fn visit_expr_if(&mut self, it: &'ast ExprIf) {
let node = self.lower_if(it);
self.emit(node);
}
fn visit_expr_match(&mut self, it: &'ast ExprMatch) {
let head = self.collect(|s| s.visit_expr(&it.expr));
for node in head {
self.emit(node);
}
let mut cases = Vec::new();
for arm in &it.arms {
let body = self.collect(|s| {
if let Some((_, guard)) = &arm.guard {
s.visit_expr(guard);
}
s.visit_expr(&arm.body);
});
cases.push(SwitchCase {
is_default: is_catch_all(&arm.pat),
body,
});
}
self.emit(Node::Switch { cases });
}
fn visit_expr_for_loop(&mut self, it: &'ast ExprForLoop) {
let body = self.collect(|s| visit::visit_expr_for_loop(s, it));
self.emit(Node::Loop { body });
}
fn visit_expr_while(&mut self, it: &'ast ExprWhile) {
let body = self.collect(|s| visit::visit_expr_while(s, it));
self.emit(Node::Loop { body });
}
fn visit_expr_loop(&mut self, it: &'ast ExprLoop) {
let body = self.collect(|s| visit::visit_expr_loop(s, it));
self.emit(Node::Loop { body });
}
fn visit_expr_break(&mut self, it: &'ast ExprBreak) {
self.emit(Node::Jump {
labeled: it.label.is_some(),
});
visit::visit_expr_break(self, it);
}
fn visit_expr_continue(&mut self, it: &'ast ExprContinue) {
self.emit(Node::Jump {
labeled: it.label.is_some(),
});
}
fn visit_expr_binary(&mut self, it: &'ast ExprBinary) {
match logical_op(&it.op) {
Some(op) => {
let mut operands = Vec::new();
self.collect_logical(it, op, &mut operands);
self.emit(Node::Logical { op, operands });
}
None => visit::visit_expr_binary(self, it),
}
}
fn visit_expr_call(&mut self, it: &'ast ExprCall) {
self.emit(Node::Call {
callee: call_path_name(&it.func),
});
visit::visit_expr_call(self, it);
}
fn visit_expr_method_call(&mut self, it: &'ast ExprMethodCall) {
self.emit(Node::Call {
callee: Some(it.method.to_string()),
});
visit::visit_expr_method_call(self, it);
}
}
fn line_of<T: Spanned>(node: &T) -> u32 {
node.span().start().line as u32
}
fn logical_op(op: &BinOp) -> Option<LogicalOp> {
match op {
BinOp::And(_) => Some(LogicalOp::And),
BinOp::Or(_) => Some(LogicalOp::Or),
_ => None,
}
}
fn is_catch_all(pat: &Pat) -> bool {
match pat {
Pat::Wild(_) => true,
Pat::Ident(p) => p.subpat.is_none(),
_ => false,
}
}
fn pat_name(pat: &Pat) -> Option<String> {
match pat {
Pat::Ident(p) => Some(p.ident.to_string()),
Pat::Type(p) => pat_name(&p.pat),
_ => None,
}
}
fn call_path_name(func: &Expr) -> Option<String> {
match func {
Expr::Path(p) => p.path.segments.last().map(|s| s.ident.to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn analyze(src: &str) -> FileReport {
analyze_source(Path::new("test.rs"), src)
}
fn find<'a>(
fns: &'a [cccc_core::report::FunctionReport],
name: &str,
) -> Option<&'a cccc_core::report::FunctionReport> {
for f in fns {
if f.name == name {
return Some(f);
}
if let Some(found) = find(&f.children, name) {
return Some(found);
}
}
None
}
fn cognitive_of(src: &str, name: &str) -> u32 {
find(&analyze(src).functions, name)
.unwrap_or_else(|| panic!("function {name} not found"))
.cognitive
}
fn cyclomatic_of(src: &str, name: &str) -> u32 {
find(&analyze(src).functions, name)
.unwrap_or_else(|| panic!("function {name} not found"))
.cyclomatic
}
#[test]
fn sonar_sum_of_primes_is_7() {
let src = r#"
fn sum_of_primes(max: u32) -> u32 {
let mut total = 0;
'out: for i in 1..=max {
for j in 2..i {
if i % j == 0 {
continue 'out;
}
}
total += i;
}
total
}
"#;
assert_eq!(cognitive_of(src, "sum_of_primes"), 7);
}
#[test]
fn sonar_get_words_is_1() {
let src = r#"
fn get_words(number: u32) -> &'static str {
match number {
1 => "one",
2 => "a couple",
_ => "lots",
}
}
"#;
assert_eq!(cognitive_of(src, "get_words"), 1);
assert_eq!(cyclomatic_of(src, "get_words"), 3);
}
#[test]
fn nested_if_adds_nesting() {
let src = r#"
fn f(a: bool, b: bool, c: bool) {
if a { if b { if c {} } }
}
"#;
assert_eq!(cognitive_of(src, "f"), 6);
}
#[test]
fn else_if_else_are_flat() {
let src = r#"
fn f(a: bool, b: bool) {
if a {} else if b {} else {}
}
"#;
assert_eq!(cognitive_of(src, "f"), 3);
}
#[test]
fn logical_sequences() {
let src = r#"
fn f(a: bool, b: bool, c: bool, d: bool) {
if a && b && c || d {}
}
"#;
assert_eq!(cognitive_of(src, "f"), 3);
assert_eq!(cyclomatic_of(src, "f"), 5);
}
#[test]
fn recursion_adds_one_per_call() {
let src = r#"
fn fib(n: u64) -> u64 {
if n < 2 { return n; }
fib(n - 1) + fib(n - 2)
}
"#;
assert_eq!(cognitive_of(src, "fib"), 3);
}
#[test]
fn method_recursion_is_detected() {
let src = r#"
struct S;
impl S {
fn walk(&self, n: u64) -> u64 {
if n == 0 { 0 } else { self.walk(n - 1) }
}
}
"#;
assert_eq!(cognitive_of(src, "walk"), 3);
}
#[test]
fn nested_function_is_independent_unit() {
let src = r#"
fn outer() {
fn inner() { if true {} }
}
"#;
assert_eq!(cognitive_of(src, "outer"), 0);
assert_eq!(cognitive_of(src, "inner"), 1);
}
#[test]
fn closure_is_its_own_unit() {
let src = r#"
fn host() {
let pick = |a: bool, b: bool| if a && b { 1 } else { 0 };
}
"#;
assert_eq!(cognitive_of(src, "host"), 0);
assert_eq!(cognitive_of(src, "pick"), 3);
}
#[test]
fn loops_all_count() {
let src = r#"
fn f() {
while true {}
for _ in 0..3 {}
loop { break; }
}
"#;
assert_eq!(cognitive_of(src, "f"), 3);
}
#[test]
fn cyclomatic_basic() {
let src = r#"
fn f(a: bool, b: bool) {
if a && b { for _ in 0..1 {} } else if b {}
}
"#;
assert_eq!(cyclomatic_of(src, "f"), 5);
}
#[test]
fn names_methods_and_closures() {
let src = r#"
fn free() {}
struct C;
impl C { fn method(&self) {} }
fn host() { let lambda = |x: u32| x + 1; }
"#;
let r = analyze(src);
assert_eq!(find(&r.functions, "free").unwrap().kind, "function");
assert_eq!(find(&r.functions, "method").unwrap().kind, "method");
assert_eq!(find(&r.functions, "lambda").unwrap().kind, "closure");
}
#[test]
fn file_total_sums_all_functions() {
let src = r#"
fn a() { if true {} }
fn b() { if true {} }
"#;
assert_eq!(analyze(src).cognitive, 2);
}
#[test]
fn parse_error_is_reported() {
let (nodes, errors) = to_ir(Path::new("bad.rs"), "fn f( {");
assert!(nodes.is_empty());
assert_eq!(errors.len(), 1);
}
}