Skip to main content

cccc_core/
engine.rs

1//! The scoring engine: walks the normalized [`crate::ir`] and computes Cognitive
2//! Complexity (SonarSource / G. Ann Campbell) and Cyclomatic Complexity (McCabe).
3//!
4//! ## Measurement model
5//!
6//! Every [`Node::Function`] is measured **independently**: its nesting level
7//! starts at 0 at its own boundary, and a structural increment is attributed
8//! only to the nearest enclosing function. Nested functions therefore do *not*
9//! inflate the enclosing function's own score; they are reported as `children`.
10//! A file's total is module-level code plus every function at every depth (each
11//! structural increment counted exactly once).
12//!
13//! ## Cyclomatic Complexity (McCabe)
14//!
15//! Base 1 per function; +1 for each branch (`if`/`else if`), ternary, loop,
16//! non-default `case`, `catch`, and each logical operator (one per extra operand
17//! in a [`Node::Logical`]).
18//!
19//! ## Cognitive Complexity (SonarSource)
20//!
21//! - +1 and +nesting bonus for: branch, ternary, switch, loop, catch.
22//! - +1 flat (no bonus) for: `else` / `else if`, labelled jumps, each logical
23//!   sequence, and recursion (a call to the nearest enclosing function's name).
24//! - Nesting increases inside branch/ternary/switch/loop/catch bodies and nested
25//!   function bodies.
26
27use crate::ir::Node;
28use crate::report::{FileReport, FunctionReport};
29
30/// An in-progress accumulator for one function-like unit (or the module root).
31///
32/// `kind` is an opaque, adapter-chosen label; the engine only ever compares it
33/// against the sentinel `"module"` (the implicit root frame) when deciding
34/// whether a call counts as recursion.
35struct Frame {
36    name: String,
37    kind: String,
38    line: u32,
39    cognitive: u32,
40    cyclomatic: u32,
41    nesting: u32,
42    children: Vec<FunctionReport>,
43}
44
45struct Engine {
46    /// `stack[0]` is always the module frame; deeper entries are functions.
47    stack: Vec<Frame>,
48}
49
50impl Engine {
51    fn new() -> Self {
52        let module = Frame {
53            name: "<module>".to_string(),
54            kind: "module".to_string(),
55            line: 1,
56            cognitive: 0,
57            cyclomatic: 0,
58            nesting: 0,
59            children: Vec::new(),
60        };
61        Self {
62            stack: vec![module],
63        }
64    }
65
66    fn top(&mut self) -> &mut Frame {
67        self.stack.last_mut().expect("stack never empty")
68    }
69
70    fn top_nesting(&self) -> u32 {
71        self.stack.last().expect("stack never empty").nesting
72    }
73
74    fn add_cognitive(&mut self, amount: u32) {
75        self.top().cognitive += amount;
76    }
77
78    fn add_cyclomatic(&mut self) {
79        self.top().cyclomatic += 1;
80    }
81
82    fn enter_nesting(&mut self) {
83        self.top().nesting += 1;
84    }
85
86    fn leave_nesting(&mut self) {
87        self.top().nesting -= 1;
88    }
89
90    /// Walk a slice of sibling nodes at the current nesting/frame.
91    fn walk(&mut self, nodes: &[Node]) {
92        for node in nodes {
93            self.visit(node);
94        }
95    }
96
97    /// The structural increment shared by loops, switch, and catch: +1 plus the
98    /// nesting bonus to cognitive, optionally a cyclomatic point, then the body
99    /// with nesting raised by one. (`switch` passes `add_cyclomatic = false` —
100    /// the switch itself is not a McCabe decision point; its cases are.)
101    fn nested(&mut self, add_cyclomatic: bool, body: &[Node]) {
102        let n = self.top_nesting();
103        self.add_cognitive(1 + n);
104        if add_cyclomatic {
105            self.add_cyclomatic();
106        }
107        self.enter_nesting();
108        self.walk(body);
109        self.leave_nesting();
110    }
111
112    fn visit(&mut self, node: &Node) {
113        match node {
114            Node::Function {
115                name,
116                kind,
117                line,
118                body,
119            } => self.visit_function(name, kind, *line, body),
120            Node::Branch {
121                test,
122                then,
123                alternate,
124            } => self.visit_branch(test, then, alternate),
125            Node::Conditional {
126                test,
127                then,
128                alternate,
129            } => {
130                let n = self.top_nesting();
131                self.add_cognitive(1 + n);
132                self.add_cyclomatic();
133                self.walk(test);
134                self.enter_nesting();
135                self.walk(then);
136                self.walk(alternate);
137                self.leave_nesting();
138            }
139            Node::Loop { body } => self.nested(true, body),
140            Node::Catch { body } => self.nested(true, body),
141            Node::Switch { cases } => {
142                let n = self.top_nesting();
143                self.add_cognitive(1 + n);
144                self.enter_nesting();
145                for case in cases {
146                    if !case.is_default {
147                        self.add_cyclomatic(); // a non-default `case` is a decision point
148                    }
149                    self.walk(&case.body);
150                }
151                self.leave_nesting();
152            }
153            Node::Jump { labeled } => {
154                if *labeled {
155                    self.add_cognitive(1);
156                }
157            }
158            Node::Logical { operands, .. } => self.visit_logical(operands),
159            Node::Call { callee } => self.visit_call(callee.as_deref()),
160            Node::Group(children) => self.walk(children),
161        }
162    }
163
164    fn visit_function(&mut self, name: &str, kind: &str, line: u32, body: &[Node]) {
165        self.stack.push(Frame {
166            name: name.to_string(),
167            kind: kind.to_string(),
168            line,
169            cognitive: 0,
170            cyclomatic: 1, // McCabe base
171            nesting: 0,
172            children: Vec::new(),
173        });
174        self.walk(body);
175        let frame = self.stack.pop().expect("function frame");
176        let report = FunctionReport {
177            name: frame.name,
178            kind: frame.kind,
179            line: frame.line,
180            cognitive: frame.cognitive,
181            cyclomatic: frame.cyclomatic,
182            children: frame.children,
183        };
184        self.top().children.push(report);
185    }
186
187    /// `if` consequent gets a nesting bonus; the alternate (`else` / `else if`)
188    /// is scored flat — one cognitive point, no bonus.
189    fn visit_branch(&mut self, test: &[Node], then: &[Node], alternate: &Option<Box<Node>>) {
190        let n = self.top_nesting();
191        self.add_cognitive(1 + n);
192        self.add_cyclomatic();
193        self.walk(test);
194        self.enter_nesting();
195        self.walk(then);
196        self.leave_nesting();
197        self.visit_alternate(alternate);
198    }
199
200    fn visit_alternate(&mut self, alternate: &Option<Box<Node>>) {
201        let Some(node) = alternate else { return };
202        match node.as_ref() {
203            // `else if`: its own decision (cyclomatic +1), flat cognitive +1.
204            Node::Branch {
205                test,
206                then,
207                alternate,
208            } => {
209                self.add_cognitive(1);
210                self.add_cyclomatic();
211                self.walk(test);
212                self.enter_nesting();
213                self.walk(then);
214                self.leave_nesting();
215                self.visit_alternate(alternate);
216            }
217            // plain `else`: flat cognitive +1, body nested.
218            other => {
219                self.add_cognitive(1);
220                self.enter_nesting();
221                self.visit(other);
222                self.leave_nesting();
223            }
224        }
225    }
226
227    /// One cognitive point for the sequence; one cyclomatic point per operator
228    /// (i.e. per extra operand). Operands are walked for nested constructs.
229    fn visit_logical(&mut self, operands: &[Node]) {
230        self.add_cognitive(1);
231        for _ in 1..operands.len() {
232            self.add_cyclomatic();
233        }
234        self.walk(operands);
235    }
236
237    fn visit_call(&mut self, callee: Option<&str>) {
238        if let Some(name) = callee
239            && let Some(top) = self.stack.last()
240            && top.kind != "module"
241            && top.name == name
242        {
243            self.add_cognitive(1); // recursion
244        }
245    }
246}
247
248/// Sum every function (all depths) into the running totals.
249fn sum_tree(fns: &[FunctionReport], cog: &mut u32, cyc: &mut u32) {
250    for f in fns {
251        *cog += f.cognitive;
252        *cyc += f.cyclomatic;
253        sum_tree(&f.children, cog, cyc);
254    }
255}
256
257/// Score a module's worth of IR (`nodes` is module-level code) into a
258/// [`FileReport`] labelled `path`. `parse_errors` is carried through verbatim
259/// for the adapter's convenience.
260pub fn analyze(path: &str, nodes: &[Node], parse_errors: Vec<String>) -> FileReport {
261    let mut engine = Engine::new();
262    engine.walk(nodes);
263    let module = engine.stack.pop().expect("module frame");
264
265    let functions = module.children;
266    let mut cognitive = module.cognitive;
267    let mut cyclomatic = module.cyclomatic;
268    sum_tree(&functions, &mut cognitive, &mut cyclomatic);
269
270    FileReport {
271        path: path.to_string(),
272        cognitive,
273        cyclomatic,
274        functions,
275        parse_errors,
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::ir::{LogicalOp, Node, SwitchCase};
283
284    fn func(name: &str, kind: &str, body: Vec<Node>) -> Node {
285        Node::Function {
286            name: name.into(),
287            kind: kind.into(),
288            line: 1,
289            body,
290        }
291    }
292    fn find<'a>(fns: &'a [FunctionReport], name: &str) -> Option<&'a FunctionReport> {
293        for f in fns {
294            if f.name == name {
295                return Some(f);
296            }
297            if let Some(found) = find(&f.children, name) {
298                return Some(found);
299            }
300        }
301        None
302    }
303    fn cog(report: &FileReport, name: &str) -> u32 {
304        find(&report.functions, name).expect("function").cognitive
305    }
306    fn cyc(report: &FileReport, name: &str) -> u32 {
307        find(&report.functions, name).expect("function").cyclomatic
308    }
309
310    // sumOfPrimes: OUT: for { for { if { continue OUT } } } -> cognitive 7.
311    #[test]
312    fn sonar_sum_of_primes_is_7() {
313        let inner_if = Node::Branch {
314            test: vec![],
315            then: vec![Node::Jump { labeled: true }],
316            alternate: None,
317        };
318        let inner_for = Node::Loop {
319            body: vec![inner_if],
320        };
321        let outer_for = Node::Loop {
322            body: vec![inner_for],
323        };
324        let f = func("sumOfPrimes", "function", vec![outer_for]);
325        let r = analyze("t", &[f], vec![]);
326        // for(+1) + nested for(+2) + nested if(+3) + continue OUT(+1) = 7
327        assert_eq!(cog(&r, "sumOfPrimes"), 7);
328    }
329
330    #[test]
331    fn sonar_get_words_is_1() {
332        let sw = Node::Switch {
333            cases: vec![
334                SwitchCase {
335                    is_default: false,
336                    body: vec![],
337                },
338                SwitchCase {
339                    is_default: false,
340                    body: vec![],
341                },
342                SwitchCase {
343                    is_default: true,
344                    body: vec![],
345                },
346            ],
347        };
348        let r = analyze("t", &[func("getWords", "function", vec![sw])], vec![]);
349        assert_eq!(cog(&r, "getWords"), 1);
350        // base 1 + 2 non-default cases = 3
351        assert_eq!(cyc(&r, "getWords"), 3);
352    }
353
354    #[test]
355    fn nested_if_adds_nesting() {
356        let deep = Node::Branch {
357            test: vec![],
358            then: vec![Node::Branch {
359                test: vec![],
360                then: vec![Node::Branch {
361                    test: vec![],
362                    then: vec![],
363                    alternate: None,
364                }],
365                alternate: None,
366            }],
367            alternate: None,
368        };
369        let r = analyze("t", &[func("f", "function", vec![deep])], vec![]);
370        assert_eq!(cog(&r, "f"), 6); // +1 +2 +3
371    }
372
373    #[test]
374    fn else_if_else_are_flat() {
375        // if {} else if {} else {}
376        let chain = Node::Branch {
377            test: vec![],
378            then: vec![],
379            alternate: Some(Box::new(Node::Branch {
380                test: vec![],
381                then: vec![],
382                alternate: Some(Box::new(Node::Group(vec![]))),
383            })),
384        };
385        let r = analyze("t", &[func("f", "function", vec![chain])], vec![]);
386        assert_eq!(cog(&r, "f"), 3); // if +1, else-if +1, else +1
387    }
388
389    #[test]
390    fn logical_sequences() {
391        // if (a && b && c || d): if(+1) + && seq(+1) + || seq(+1) = 3
392        let inner_and = Node::Logical {
393            op: LogicalOp::And,
394            operands: vec![
395                Node::Group(vec![]),
396                Node::Group(vec![]),
397                Node::Group(vec![]),
398            ],
399        };
400        let outer_or = Node::Logical {
401            op: LogicalOp::Or,
402            operands: vec![inner_and, Node::Group(vec![])],
403        };
404        let branch = Node::Branch {
405            test: vec![outer_or],
406            then: vec![],
407            alternate: None,
408        };
409        let r = analyze("t", &[func("f", "function", vec![branch])], vec![]);
410        assert_eq!(cog(&r, "f"), 3);
411        // cyc: base1 + if1 + (&& has 3 operands => +2) + (|| has 2 => +1) = 5
412        assert_eq!(cyc(&r, "f"), 5);
413    }
414
415    #[test]
416    fn recursion_adds_one() {
417        // fib: if(+1) + call fib (+1 recursion)
418        let body = vec![
419            Node::Branch {
420                test: vec![],
421                then: vec![],
422                alternate: None,
423            },
424            Node::Call {
425                callee: Some("fib".into()),
426            },
427        ];
428        let r = analyze("t", &[func("fib", "function", body)], vec![]);
429        assert_eq!(cog(&r, "fib"), 2);
430    }
431
432    #[test]
433    fn nested_function_is_independent_unit() {
434        let inner = func(
435            "inner",
436            "function",
437            vec![Node::Branch {
438                test: vec![],
439                then: vec![],
440                alternate: None,
441            }],
442        );
443        let outer = func("outer", "function", vec![inner]);
444        let r = analyze("t", &[outer], vec![]);
445        assert_eq!(cog(&r, "outer"), 0); // child excluded from parent's own score
446        assert_eq!(cog(&r, "inner"), 1);
447    }
448
449    #[test]
450    fn file_total_sums_all_functions() {
451        let a = func(
452            "a",
453            "function",
454            vec![Node::Branch {
455                test: vec![],
456                then: vec![],
457                alternate: None,
458            }],
459        );
460        let b = func(
461            "b",
462            "function",
463            vec![Node::Branch {
464                test: vec![],
465                then: vec![],
466                alternate: None,
467            }],
468        );
469        let r = analyze("t", &[a, b], vec![]);
470        assert_eq!(r.cognitive, 2);
471    }
472}