Skip to main content

cccc_rs/
lib.rs

1//! Rust adapter: parses source with [syn](https://docs.rs/syn) and lowers the
2//! AST into the language-agnostic [`cccc_core::ir`].
3//!
4//! This is a pure library — it depends only on `cccc-core` and `syn`, with no
5//! CLI machinery, so embedders pay nothing for clap/ignore/rayon. The `cccc-rs`
6//! binary lives in the separate `cccc-rs-cli` crate, which combines
7//! [`analyze_source`]/[`DEFAULT_EXTS`] with the shared `cccc-cli` runner.
8//!
9//! This crate contains **no scoring logic** — it only recognizes the constructs
10//! the engine cares about (functions/methods/closures, `if`/`else`, `match`,
11//! loops, labelled jumps, `&&`/`||` sequences, calls) and emits the matching IR
12//! nodes. All complexity rules live in [`cccc_core::engine`].
13//!
14//! ## Why a `Visit`-driven builder
15//!
16//! Lowering is driven by syn's [`Visit`] trait. Its default `visit_*` methods
17//! traverse the *entire* AST; we override only the nodes that produce IR, so a
18//! nested function or logical operator appearing in any expression position is
19//! still reached — we never have to enumerate every node kind by hand. The IR
20//! tree is assembled with a stack of "collectors": [`Builder::collect`] pushes a
21//! fresh child vector, runs a sub-traversal, and pops the nodes it gathered.
22//!
23//! ## Rust-to-IR mapping notes
24//!
25//! - `fn` / `impl` method / trait default method / closure → [`Node::Function`].
26//! - `if` / `else if` / `else` → [`Node::Branch`] (chaining `else if` as a nested
27//!   `Branch` so it scores flat). `if let` / `while let` are just the same nodes.
28//! - `for` / `while` / `loop` → [`Node::Loop`].
29//! - `match` → [`Node::Switch`]; a `_` (or bare binding) arm is the `default`.
30//!   An arm guard (`pat if cond`) is visited inside the case body.
31//! - labelled `break 'a` / `continue 'a` → [`Node::Jump`] (`labeled: true`).
32//! - `&&` / `||` runs → folded [`Node::Logical`] (one node per like-operator run).
33//! - calls (`f(..)`, `obj.m(..)`) → [`Node::Call`] for recursion detection.
34//!
35//! Rust has no ternary (`if` is an expression instead) and no `try`/`catch`
36//! (errors propagate via `?`), so no `Conditional`/`Catch` nodes are emitted.
37
38use std::path::Path;
39
40use cccc_core::engine;
41use cccc_core::ir::{LogicalOp, Node, SwitchCase};
42use cccc_core::report::FileReport;
43use syn::spanned::Spanned;
44use syn::visit::{self, Visit};
45use syn::{
46    BinOp, Expr, ExprBinary, ExprBreak, ExprCall, ExprClosure, ExprContinue, ExprForLoop, ExprIf,
47    ExprLoop, ExprMatch, ExprMethodCall, ExprWhile, ImplItemFn, ItemFn, Local, Pat, TraitItemFn,
48};
49
50/// File extensions analyzed by default (when `--ext` is not given).
51pub const DEFAULT_EXTS: &[&str] = &["rs"];
52
53/// Parse `source` and produce its [`FileReport`], scoring via the core engine.
54/// This is the convenience entry point used by the CLI; for the raw IR (e.g. to
55/// feed a different consumer) use [`to_ir`].
56pub fn analyze_source(path: &Path, source: &str) -> FileReport {
57    let (nodes, parse_errors) = to_ir(path, source);
58    engine::analyze(&path.display().to_string(), &nodes, parse_errors)
59}
60
61/// Parse `source` and lower it to the complexity IR, returning the module-level
62/// nodes plus any parser error messages. `syn` parses a whole file at once and
63/// does not recover from syntax errors, so a parse failure yields an empty node
64/// list and a single error string.
65pub fn to_ir(_path: &Path, source: &str) -> (Vec<Node>, Vec<String>) {
66    match syn::parse_file(source) {
67        Ok(file) => {
68            let mut builder = Builder::new();
69            for item in &file.items {
70                builder.visit_item(item);
71            }
72            (builder.finish(), Vec::new())
73        }
74        Err(e) => (Vec::new(), vec![e.to_string()]),
75    }
76}
77
78/// Assembles the IR tree while syn's `Visit` drives a complete AST traversal.
79struct Builder {
80    /// Stack of node collectors. `stack.last_mut()` receives emitted nodes;
81    /// structural nodes push a fresh collector for their body, then pop it.
82    stack: Vec<Vec<Node>>,
83    /// Name captured from a `let` binding to label the next closure.
84    pending_name: Option<String>,
85}
86
87impl Builder {
88    fn new() -> Self {
89        Self {
90            stack: vec![Vec::new()], // module-level collector
91            pending_name: None,
92        }
93    }
94
95    /// The module-level node list (the single remaining collector).
96    fn finish(mut self) -> Vec<Node> {
97        self.stack.pop().expect("module collector")
98    }
99
100    /// Append a node to the current collector.
101    fn emit(&mut self, node: Node) {
102        self.stack.last_mut().expect("collector").push(node);
103    }
104
105    /// Run `f` against a fresh collector and return the nodes it gathered.
106    fn collect<F: FnOnce(&mut Self)>(&mut self, f: F) -> Vec<Node> {
107        self.stack.push(Vec::new());
108        f(self);
109        self.stack.pop().expect("collector")
110    }
111
112    /// Emit a `Function` whose body is whatever `walk` gathers in a sub-traversal.
113    fn emit_function<F: FnOnce(&mut Self)>(
114        &mut self,
115        name: String,
116        kind: &'static str,
117        line: u32,
118        walk: F,
119    ) {
120        let body = self.collect(walk);
121        self.emit(Node::Function {
122            name,
123            kind: kind.to_string(),
124            line,
125            body,
126        });
127    }
128
129    /// Build an `if` (recursively, so `else if` becomes a nested `Branch`).
130    fn lower_if(&mut self, it: &ExprIf) -> Node {
131        let test = self.collect(|s| s.visit_expr(&it.cond));
132        let then = self.collect(|s| s.visit_block(&it.then_branch));
133        let alternate = it
134            .else_branch
135            .as_ref()
136            .map(|(_, alt)| Box::new(self.lower_alternate(alt)));
137        Node::Branch {
138            test,
139            then,
140            alternate,
141        }
142    }
143
144    /// `else if` → nested `Branch`; plain `else { .. }` → `Group`.
145    fn lower_alternate(&mut self, expr: &Expr) -> Node {
146        match expr {
147            Expr::If(elif) => self.lower_if(elif),
148            other => Node::Group(self.collect(|s| s.visit_expr(other))),
149        }
150    }
151
152    /// Flatten same-operator operands; a different operator nests as its own
153    /// `Logical`; any other expression becomes a `Group` of its sub-nodes.
154    fn collect_logical(&mut self, expr: &ExprBinary, op: LogicalOp, operands: &mut Vec<Node>) {
155        self.collect_logical_side(&expr.left, op, operands);
156        self.collect_logical_side(&expr.right, op, operands);
157    }
158
159    fn collect_logical_side(&mut self, side: &Expr, op: LogicalOp, operands: &mut Vec<Node>) {
160        match side {
161            Expr::Binary(inner) => match logical_op(&inner.op) {
162                Some(inner_op) if inner_op == op => self.collect_logical(inner, op, operands),
163                Some(inner_op) => {
164                    let mut sub = Vec::new();
165                    self.collect_logical(inner, inner_op, &mut sub);
166                    operands.push(Node::Logical {
167                        op: inner_op,
168                        operands: sub,
169                    });
170                }
171                None => operands.push(Node::Group(self.collect(|s| s.visit_expr(side)))),
172            },
173            Expr::Paren(p) => self.collect_logical_side(&p.expr, op, operands),
174            Expr::Group(g) => self.collect_logical_side(&g.expr, op, operands),
175            _ => operands.push(Node::Group(self.collect(|s| s.visit_expr(side)))),
176        }
177    }
178}
179
180impl<'ast> Visit<'ast> for Builder {
181    fn visit_item_fn(&mut self, it: &'ast ItemFn) {
182        let name = it.sig.ident.to_string();
183        let line = line_of(&it.sig.ident);
184        self.emit_function(name, "function", line, |s| visit::visit_item_fn(s, it));
185    }
186
187    fn visit_impl_item_fn(&mut self, it: &'ast ImplItemFn) {
188        let name = it.sig.ident.to_string();
189        let line = line_of(&it.sig.ident);
190        self.emit_function(name, "method", line, |s| visit::visit_impl_item_fn(s, it));
191    }
192
193    fn visit_trait_item_fn(&mut self, it: &'ast TraitItemFn) {
194        // Only a default-bodied trait method is a measurable unit; a bare
195        // signature carries no complexity, so don't report it as a function.
196        if it.default.is_some() {
197            let name = it.sig.ident.to_string();
198            let line = line_of(&it.sig.ident);
199            self.emit_function(name, "method", line, |s| visit::visit_trait_item_fn(s, it));
200        }
201    }
202
203    fn visit_local(&mut self, it: &'ast Local) {
204        if let Some(init) = &it.init
205            && matches!(&*init.expr, Expr::Closure(_))
206        {
207            self.pending_name = pat_name(&it.pat);
208        }
209        visit::visit_local(self, it);
210    }
211
212    fn visit_expr_closure(&mut self, it: &'ast ExprClosure) {
213        let name = self
214            .pending_name
215            .take()
216            .unwrap_or_else(|| "<closure>".to_string());
217        let line = line_of(it);
218        self.emit_function(name, "closure", line, |s| visit::visit_expr_closure(s, it));
219    }
220
221    fn visit_expr_if(&mut self, it: &'ast ExprIf) {
222        let node = self.lower_if(it);
223        self.emit(node);
224    }
225
226    fn visit_expr_match(&mut self, it: &'ast ExprMatch) {
227        // Visit the scrutinee at the match's own level (matches walk order),
228        // then gather each arm body.
229        let head = self.collect(|s| s.visit_expr(&it.expr));
230        for node in head {
231            self.emit(node);
232        }
233        let mut cases = Vec::new();
234        for arm in &it.arms {
235            let body = self.collect(|s| {
236                if let Some((_, guard)) = &arm.guard {
237                    s.visit_expr(guard);
238                }
239                s.visit_expr(&arm.body);
240            });
241            cases.push(SwitchCase {
242                is_default: is_catch_all(&arm.pat),
243                body,
244            });
245        }
246        self.emit(Node::Switch { cases });
247    }
248
249    fn visit_expr_for_loop(&mut self, it: &'ast ExprForLoop) {
250        let body = self.collect(|s| visit::visit_expr_for_loop(s, it));
251        self.emit(Node::Loop { body });
252    }
253
254    fn visit_expr_while(&mut self, it: &'ast ExprWhile) {
255        let body = self.collect(|s| visit::visit_expr_while(s, it));
256        self.emit(Node::Loop { body });
257    }
258
259    fn visit_expr_loop(&mut self, it: &'ast ExprLoop) {
260        let body = self.collect(|s| visit::visit_expr_loop(s, it));
261        self.emit(Node::Loop { body });
262    }
263
264    fn visit_expr_break(&mut self, it: &'ast ExprBreak) {
265        self.emit(Node::Jump {
266            labeled: it.label.is_some(),
267        });
268        visit::visit_expr_break(self, it);
269    }
270
271    fn visit_expr_continue(&mut self, it: &'ast ExprContinue) {
272        self.emit(Node::Jump {
273            labeled: it.label.is_some(),
274        });
275    }
276
277    fn visit_expr_binary(&mut self, it: &'ast ExprBinary) {
278        match logical_op(&it.op) {
279            Some(op) => {
280                let mut operands = Vec::new();
281                self.collect_logical(it, op, &mut operands);
282                self.emit(Node::Logical { op, operands });
283            }
284            None => visit::visit_expr_binary(self, it),
285        }
286    }
287
288    fn visit_expr_call(&mut self, it: &'ast ExprCall) {
289        self.emit(Node::Call {
290            callee: call_path_name(&it.func),
291        });
292        visit::visit_expr_call(self, it);
293    }
294
295    fn visit_expr_method_call(&mut self, it: &'ast ExprMethodCall) {
296        self.emit(Node::Call {
297            callee: Some(it.method.to_string()),
298        });
299        visit::visit_expr_method_call(self, it);
300    }
301}
302
303/// 1-based start line of any spanned node (requires proc-macro2 span-locations).
304fn line_of<T: Spanned>(node: &T) -> u32 {
305    node.span().start().line as u32
306}
307
308/// `&&` / `||` map to the normalized logical ops; everything else is not a
309/// logical sequence. (Rust has no nullish-coalescing operator.)
310fn logical_op(op: &BinOp) -> Option<LogicalOp> {
311    match op {
312        BinOp::And(_) => Some(LogicalOp::And),
313        BinOp::Or(_) => Some(LogicalOp::Or),
314        _ => None,
315    }
316}
317
318/// A `match` arm that always matches: `_` or a bare binding (`other => ..`).
319fn is_catch_all(pat: &Pat) -> bool {
320    match pat {
321        Pat::Wild(_) => true,
322        Pat::Ident(p) => p.subpat.is_none(),
323        _ => false,
324    }
325}
326
327/// Name bound by a `let` pattern, unwrapping a type ascription.
328fn pat_name(pat: &Pat) -> Option<String> {
329    match pat {
330        Pat::Ident(p) => Some(p.ident.to_string()),
331        Pat::Type(p) => pat_name(&p.pat),
332        _ => None,
333    }
334}
335
336/// Simple name of a directly-called callee (`foo(..)` or `path::foo(..)`), used
337/// for recursion detection. Returns the last path segment.
338fn call_path_name(func: &Expr) -> Option<String> {
339    match func {
340        Expr::Path(p) => p.path.segments.last().map(|s| s.ident.to_string()),
341        _ => None,
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn analyze(src: &str) -> FileReport {
350        analyze_source(Path::new("test.rs"), src)
351    }
352
353    fn find<'a>(
354        fns: &'a [cccc_core::report::FunctionReport],
355        name: &str,
356    ) -> Option<&'a cccc_core::report::FunctionReport> {
357        for f in fns {
358            if f.name == name {
359                return Some(f);
360            }
361            if let Some(found) = find(&f.children, name) {
362                return Some(found);
363            }
364        }
365        None
366    }
367
368    fn cognitive_of(src: &str, name: &str) -> u32 {
369        find(&analyze(src).functions, name)
370            .unwrap_or_else(|| panic!("function {name} not found"))
371            .cognitive
372    }
373
374    fn cyclomatic_of(src: &str, name: &str) -> u32 {
375        find(&analyze(src).functions, name)
376            .unwrap_or_else(|| panic!("function {name} not found"))
377            .cyclomatic
378    }
379
380    #[test]
381    fn sonar_sum_of_primes_is_7() {
382        let src = r#"
383            fn sum_of_primes(max: u32) -> u32 {
384                let mut total = 0;
385                'out: for i in 1..=max {
386                    for j in 2..i {
387                        if i % j == 0 {
388                            continue 'out;
389                        }
390                    }
391                    total += i;
392                }
393                total
394            }
395        "#;
396        // for(+1) + nested for(+2) + nested if(+3) + labelled continue(+1) = 7
397        assert_eq!(cognitive_of(src, "sum_of_primes"), 7);
398    }
399
400    #[test]
401    fn sonar_get_words_is_1() {
402        let src = r#"
403            fn get_words(number: u32) -> &'static str {
404                match number {
405                    1 => "one",
406                    2 => "a couple",
407                    _ => "lots",
408                }
409            }
410        "#;
411        assert_eq!(cognitive_of(src, "get_words"), 1);
412        // base 1 + 2 non-default arms = 3
413        assert_eq!(cyclomatic_of(src, "get_words"), 3);
414    }
415
416    #[test]
417    fn nested_if_adds_nesting() {
418        let src = r#"
419            fn f(a: bool, b: bool, c: bool) {
420                if a { if b { if c {} } }
421            }
422        "#;
423        assert_eq!(cognitive_of(src, "f"), 6);
424    }
425
426    #[test]
427    fn else_if_else_are_flat() {
428        let src = r#"
429            fn f(a: bool, b: bool) {
430                if a {} else if b {} else {}
431            }
432        "#;
433        assert_eq!(cognitive_of(src, "f"), 3);
434    }
435
436    #[test]
437    fn logical_sequences() {
438        let src = r#"
439            fn f(a: bool, b: bool, c: bool, d: bool) {
440                if a && b && c || d {}
441            }
442        "#;
443        // if(+1) + && seq(+1) + || seq(+1) = 3
444        assert_eq!(cognitive_of(src, "f"), 3);
445        // base 1 + if 1 + (&& 3 operands => +2) + (|| 2 operands => +1) = 5
446        assert_eq!(cyclomatic_of(src, "f"), 5);
447    }
448
449    #[test]
450    fn recursion_adds_one_per_call() {
451        let src = r#"
452            fn fib(n: u64) -> u64 {
453                if n < 2 { return n; }
454                fib(n - 1) + fib(n - 2)
455            }
456        "#;
457        // if(+1) + two recursive calls(+2) = 3
458        assert_eq!(cognitive_of(src, "fib"), 3);
459    }
460
461    #[test]
462    fn method_recursion_is_detected() {
463        let src = r#"
464            struct S;
465            impl S {
466                fn walk(&self, n: u64) -> u64 {
467                    if n == 0 { 0 } else { self.walk(n - 1) }
468                }
469            }
470        "#;
471        // if/else: if(+1) + else(+1) + recursion(+1) = 3
472        assert_eq!(cognitive_of(src, "walk"), 3);
473    }
474
475    #[test]
476    fn nested_function_is_independent_unit() {
477        let src = r#"
478            fn outer() {
479                fn inner() { if true {} }
480            }
481        "#;
482        assert_eq!(cognitive_of(src, "outer"), 0);
483        assert_eq!(cognitive_of(src, "inner"), 1);
484    }
485
486    #[test]
487    fn closure_is_its_own_unit() {
488        let src = r#"
489            fn host() {
490                let pick = |a: bool, b: bool| if a && b { 1 } else { 0 };
491            }
492        "#;
493        // host owns no structural complexity; the closure does.
494        assert_eq!(cognitive_of(src, "host"), 0);
495        // if(+1) + && seq(+1) + else(+1) = 3
496        assert_eq!(cognitive_of(src, "pick"), 3);
497    }
498
499    #[test]
500    fn loops_all_count() {
501        let src = r#"
502            fn f() {
503                while true {}
504                for _ in 0..3 {}
505                loop { break; }
506            }
507        "#;
508        // three loops, each +1 at nesting 0
509        assert_eq!(cognitive_of(src, "f"), 3);
510    }
511
512    #[test]
513    fn cyclomatic_basic() {
514        let src = r#"
515            fn f(a: bool, b: bool) {
516                if a && b { for _ in 0..1 {} } else if b {}
517            }
518        "#;
519        // base 1 + if 1 + (&& => +1) + for 1 + else if 1 = 5
520        assert_eq!(cyclomatic_of(src, "f"), 5);
521    }
522
523    #[test]
524    fn names_methods_and_closures() {
525        let src = r#"
526            fn free() {}
527            struct C;
528            impl C { fn method(&self) {} }
529            fn host() { let lambda = |x: u32| x + 1; }
530        "#;
531        let r = analyze(src);
532        assert_eq!(find(&r.functions, "free").unwrap().kind, "function");
533        assert_eq!(find(&r.functions, "method").unwrap().kind, "method");
534        assert_eq!(find(&r.functions, "lambda").unwrap().kind, "closure");
535    }
536
537    #[test]
538    fn file_total_sums_all_functions() {
539        let src = r#"
540            fn a() { if true {} }
541            fn b() { if true {} }
542        "#;
543        assert_eq!(analyze(src).cognitive, 2);
544    }
545
546    #[test]
547    fn parse_error_is_reported() {
548        let (nodes, errors) = to_ir(Path::new("bad.rs"), "fn f( {");
549        assert!(nodes.is_empty());
550        assert_eq!(errors.len(), 1);
551    }
552}