Skip to main content

cargo_crap/
complexity.rs

1//! Extract cyclomatic complexity per function, with source spans.
2//!
3//! We use [`syn`] for two reasons beyond just getting a CC number: it gives
4//! us the typed Rust AST with precise line spans for every function, and it
5//! handles free functions, impl methods, and nested scopes uniformly via its
6//! [`Visit`] trait. LCOV's `FN:line,name` record only gives us the starting
7//! line — the span has to come from the AST.
8
9use anyhow::{Context, Result};
10use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
11use rayon::prelude::*;
12use serde::Serialize;
13use std::path::{Path, PathBuf};
14use syn::{
15    BinOp, ImplItemFn, ItemFn, ItemImpl,
16    visit::{self, Visit},
17};
18
19/// One function's complexity, with enough location info to join against a
20/// coverage report later.
21#[derive(Debug, Clone, Serialize)]
22pub struct FunctionComplexity {
23    /// Absolute path to the source file.
24    pub file: PathBuf,
25    /// Function name. Closures are not extracted as separate entries.
26    pub name: String,
27    /// 1-indexed first line of the function (inclusive).
28    pub start_line: usize,
29    /// 1-indexed last line of the function (inclusive).
30    pub end_line: usize,
31    /// `McCabe` cyclomatic complexity, minimum 1.0.
32    pub cyclomatic: f64,
33}
34
35/// Analyze a single Rust source file and return every function found.
36///
37/// Top-level module scope (the file itself) is intentionally excluded —
38/// CRAP is a per-function metric, and rolling up file-level CC into the
39/// formula produces misleading scores on large files.
40pub fn analyze_file(path: &Path) -> Result<Vec<FunctionComplexity>> {
41    let source = std::fs::read_to_string(path)
42        .with_context(|| format!("reading source file {}", path.display()))?;
43
44    let syntax = syn::parse_file(&source).with_context(|| format!("parsing {}", path.display()))?;
45
46    let mut visitor = FunctionVisitor {
47        file: path,
48        out: Vec::new(),
49        impl_type: None,
50    };
51    visitor.visit_file(&syntax);
52    Ok(visitor.out)
53}
54
55/// Returns `true` if `attrs` contains an attribute with the given simple name,
56/// e.g. `has_attr(attrs, "test")` matches `#[test]`.
57fn has_attr(
58    attrs: &[syn::Attribute],
59    name: &str,
60) -> bool {
61    attrs.iter().any(|a| a.path().is_ident(name))
62}
63
64/// Returns `true` if `attrs` contains `#[cfg(test)]` exactly.
65///
66/// More complex forms (`#[cfg(not(test))]`, `#[cfg(any(test, ...))]`) are not
67/// matched — we only skip the common, unambiguous case.
68fn is_cfg_test(attrs: &[syn::Attribute]) -> bool {
69    attrs.iter().any(|a| {
70        a.path().is_ident("cfg") && a.parse_args::<syn::Ident>().is_ok_and(|id| id == "test")
71    })
72}
73
74/// Extract a simple type name from an `impl` self-type for use as a prefix.
75///
76/// `impl Foo` and `impl Trait for Foo` both yield `Some("Foo")`.
77/// Exotic cases like `impl dyn Trait` yield `None`.
78fn impl_type_name(ty: &syn::Type) -> Option<String> {
79    if let syn::Type::Path(tp) = ty {
80        tp.path.segments.last().map(|s| s.ident.to_string())
81    } else {
82        None
83    }
84}
85
86/// syn visitor that collects one [`FunctionComplexity`] per function item.
87struct FunctionVisitor<'a> {
88    file: &'a Path,
89    out: Vec<FunctionComplexity>,
90    /// Type name of the enclosing `impl` block, if any.
91    impl_type: Option<String>,
92}
93
94impl<'ast> Visit<'ast> for FunctionVisitor<'_> {
95    fn visit_item_fn(
96        &mut self,
97        node: &'ast ItemFn,
98    ) {
99        // Skip test functions — they are never in LCOV output and would
100        // always score as 0% covered, producing misleading CRAP scores.
101        if has_attr(&node.attrs, "test") {
102            return;
103        }
104        let name = node.sig.ident.to_string();
105        let start_line = node.sig.fn_token.span.start().line;
106        let end_line = node.block.brace_token.span.close().end().line;
107        let cyclomatic = count_cyclomatic(&node.block) as f64;
108        self.out.push(FunctionComplexity {
109            file: self.file.to_path_buf(),
110            name,
111            start_line,
112            end_line,
113            cyclomatic,
114        });
115        // Do NOT recurse: skip nested fn items inside function bodies.
116    }
117
118    fn visit_item_impl(
119        &mut self,
120        node: &'ast ItemImpl,
121    ) {
122        // Set the self-type for the duration of this impl block so that
123        // visit_impl_item_fn can prefix method names with it.
124        let prev = self.impl_type.take();
125        self.impl_type = impl_type_name(&node.self_ty);
126        visit::visit_item_impl(self, node);
127        self.impl_type = prev;
128    }
129
130    fn visit_impl_item_fn(
131        &mut self,
132        node: &'ast ImplItemFn,
133    ) {
134        if has_attr(&node.attrs, "test") {
135            return;
136        }
137        let method = node.sig.ident.to_string();
138        let name = match &self.impl_type {
139            Some(ty) => format!("{ty}::{method}"),
140            None => method,
141        };
142        let start_line = node.sig.fn_token.span.start().line;
143        let end_line = node.block.brace_token.span.close().end().line;
144        let cyclomatic = count_cyclomatic(&node.block) as f64;
145        self.out.push(FunctionComplexity {
146            file: self.file.to_path_buf(),
147            name,
148            start_line,
149            end_line,
150            cyclomatic,
151        });
152    }
153
154    fn visit_item_mod(
155        &mut self,
156        node: &'ast syn::ItemMod,
157    ) {
158        // Skip the entire #[cfg(test)] module — functions inside it will
159        // never appear in coverage reports and would all score pessimistically.
160        if !is_cfg_test(&node.attrs) {
161            visit::visit_item_mod(self, node);
162        }
163    }
164}
165
166/// Compute cyclomatic complexity for a function body.
167///
168/// Base count is 1 (the single straight-line path). Each decision point adds 1.
169fn count_cyclomatic(body: &syn::Block) -> usize {
170    let mut counter = CcCounter { count: 1 };
171    counter.visit_block(body);
172    counter.count
173}
174
175/// Visitor that counts decision points to compute cyclomatic complexity.
176struct CcCounter {
177    count: usize,
178}
179
180impl<'ast> Visit<'ast> for CcCounter {
181    fn visit_expr_if(
182        &mut self,
183        node: &'ast syn::ExprIf,
184    ) {
185        self.count += 1;
186        visit::visit_expr_if(self, node); // recurse to catch else-if chains
187    }
188
189    fn visit_expr_for_loop(
190        &mut self,
191        node: &'ast syn::ExprForLoop,
192    ) {
193        self.count += 1;
194        visit::visit_expr_for_loop(self, node);
195    }
196
197    fn visit_expr_while(
198        &mut self,
199        node: &'ast syn::ExprWhile,
200    ) {
201        self.count += 1;
202        visit::visit_expr_while(self, node);
203    }
204
205    fn visit_expr_loop(
206        &mut self,
207        node: &'ast syn::ExprLoop,
208    ) {
209        self.count += 1;
210        visit::visit_expr_loop(self, node);
211    }
212
213    fn visit_arm(
214        &mut self,
215        node: &'ast syn::Arm,
216    ) {
217        self.count += 1;
218        visit::visit_arm(self, node);
219    }
220
221    fn visit_expr_binary(
222        &mut self,
223        node: &'ast syn::ExprBinary,
224    ) {
225        if matches!(node.op, BinOp::And(_) | BinOp::Or(_)) {
226            self.count += 1;
227        }
228        visit::visit_expr_binary(self, node);
229    }
230
231    fn visit_expr_try(
232        &mut self,
233        node: &'ast syn::ExprTry,
234    ) {
235        self.count += 1;
236        visit::visit_expr_try(self, node);
237    }
238
239    fn visit_expr_closure(
240        &mut self,
241        _node: &'ast syn::ExprClosure,
242    ) {
243        // Do not recurse into closures: their decision points belong to their
244        // own logical scope, not to the enclosing function's CC.
245    }
246}
247
248/// Build a `GlobSet` from a slice of glob pattern strings.
249fn build_exclude_set<S: AsRef<str>>(patterns: &[S]) -> Result<GlobSet> {
250    let mut builder = GlobSetBuilder::new();
251    for pat in patterns {
252        let glob = GlobBuilder::new(pat.as_ref())
253            .literal_separator(true) // `*` stays within one component; `**` crosses
254            .build()
255            .with_context(|| format!("invalid exclude pattern: {:?}", pat.as_ref()))?;
256        builder.add(glob);
257    }
258    builder.build().context("building exclude glob set")
259}
260
261/// Walk a directory tree and analyze every `.rs` file, honoring `.gitignore`.
262///
263/// `excludes` is a list of glob patterns (relative to `root`) for paths that
264/// should be skipped. Use `**` to cross directory boundaries:
265/// `"tests/**"` excludes all files under `tests/`.
266///
267/// Files that fail to parse are logged to stderr but do not abort the whole
268/// run — one corrupt file in a 10k-file workspace shouldn't break CI.
269pub fn analyze_tree<S: AsRef<str>>(
270    root: &Path,
271    excludes: &[S],
272) -> Result<Vec<FunctionComplexity>> {
273    let exclude_set = build_exclude_set(excludes)?;
274
275    // Phase 1: collect eligible paths (single-threaded walk — the filesystem
276    // is inherently sequential and the ignore crate is not Send).
277    let paths: Vec<PathBuf> = {
278        let walker = ignore::WalkBuilder::new(root)
279            .standard_filters(true)
280            .build();
281
282        walker
283            .filter_map(|result| {
284                let entry = match result {
285                    Ok(e) => e,
286                    Err(err) => {
287                        eprintln!("warning: walk error: {err}");
288                        return None;
289                    },
290                };
291                if !entry.file_type().is_some_and(|t| t.is_file()) {
292                    return None;
293                }
294                if entry.path().extension().and_then(|e| e.to_str()) != Some("rs") {
295                    return None;
296                }
297                if !exclude_set.is_empty()
298                    && let Ok(rel) = entry.path().strip_prefix(root)
299                    && exclude_set.is_match(rel)
300                {
301                    return None;
302                }
303                Some(entry.path().to_path_buf())
304            })
305            .collect()
306    };
307
308    // Phase 2: analyze files in parallel. Each file is independent so rayon
309    // can schedule them across all available cores with no synchronization.
310    let all: Vec<FunctionComplexity> = paths
311        .par_iter()
312        .flat_map_iter(|path| match analyze_file(path) {
313            Ok(fns) => fns,
314            Err(err) => {
315                eprintln!("warning: could not analyze {}: {err}", path.display());
316                vec![]
317            },
318        })
319        .collect();
320
321    Ok(all)
322}
323
324#[cfg(test)]
325#[expect(
326    clippy::float_cmp,
327    reason = "CC counter increments by integer steps stored as f64; exact equality is the right comparison"
328)]
329mod tests {
330    use super::*;
331    use std::io::Write;
332
333    fn write_temp(source: &str) -> tempfile::NamedTempFile {
334        let mut f = tempfile::Builder::new()
335            .suffix(".rs")
336            .tempfile()
337            .expect("tempfile");
338        f.write_all(source.as_bytes()).expect("write");
339        f
340    }
341
342    #[test]
343    fn trivial_function_has_cc_one() {
344        let f = write_temp("fn hello() -> i32 { 42 }");
345        let fns = analyze_file(f.path()).expect("analyze");
346        assert_eq!(fns.len(), 1);
347        assert_eq!(fns[0].name, "hello");
348        assert_eq!(fns[0].cyclomatic, 1.0);
349    }
350
351    #[test]
352    fn branching_increases_cc() {
353        let f = write_temp(
354            r#"
355fn check(x: i32) -> &'static str {
356    if x < 0 {
357        "neg"
358    } else if x == 0 {
359        "zero"
360    } else {
361        "pos"
362    }
363}
364"#,
365        );
366        let fns = analyze_file(f.path()).expect("analyze");
367        assert_eq!(fns.len(), 1);
368        assert!(
369            fns[0].cyclomatic >= 3.0,
370            "expected CC ≥ 3 for two-branch if/else, got {}",
371            fns[0].cyclomatic
372        );
373    }
374
375    #[test]
376    fn multiple_functions_are_all_found() {
377        let f = write_temp(
378            r"
379fn a() {}
380fn b() {}
381fn c() {}
382",
383        );
384        let fns = analyze_file(f.path()).expect("analyze");
385        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
386        assert!(names.contains(&"a"));
387        assert!(names.contains(&"b"));
388        assert!(names.contains(&"c"));
389    }
390
391    #[test]
392    fn for_loop_adds_one_to_cc() {
393        // Kills: visit_expr_for_loop replaced with (), += with -=, += with *=
394        let f = write_temp("fn foo(n: i32) -> i32 { let mut s = 0; for _i in 0..n { s += 1; } s }");
395        let fns = analyze_file(f.path()).expect("analyze");
396        assert_eq!(
397            fns[0].cyclomatic, 2.0,
398            "for loop must add exactly 1 to base CC"
399        );
400    }
401
402    #[test]
403    fn while_loop_adds_one_to_cc() {
404        // Kills: visit_expr_while replaced with (), += with -=, += with *=
405        let f = write_temp("fn foo(mut n: i32) -> i32 { while n > 0 { n -= 1; } n }");
406        let fns = analyze_file(f.path()).expect("analyze");
407        assert_eq!(
408            fns[0].cyclomatic, 2.0,
409            "while loop must add exactly 1 to base CC"
410        );
411    }
412
413    #[test]
414    fn loop_expr_adds_one_to_cc() {
415        // Kills: visit_expr_loop replaced with (), += with -=, += with *=
416        let f = write_temp("fn foo() { loop { break; } }");
417        let fns = analyze_file(f.path()).expect("analyze");
418        assert_eq!(fns[0].cyclomatic, 2.0, "loop must add exactly 1 to base CC");
419    }
420
421    #[test]
422    fn match_arms_each_add_one_to_cc() {
423        // Kills: visit_arm replaced with (), += with -=, += with *=
424        let f = write_temp("fn foo(x: u8) -> u8 { match x { 0 => 1, 1 => 2, _ => 3 } }");
425        let fns = analyze_file(f.path()).expect("analyze");
426        assert_eq!(fns[0].cyclomatic, 4.0, "3-arm match must add 3 to base CC");
427    }
428
429    #[test]
430    fn logical_and_adds_one_to_cc() {
431        // Kills: visit_expr_binary replaced with (), += with -=, += with *=
432        let f = write_temp("fn foo(a: bool, b: bool) -> bool { a && b }");
433        let fns = analyze_file(f.path()).expect("analyze");
434        assert_eq!(fns[0].cyclomatic, 2.0, "&& must add exactly 1 to base CC");
435    }
436
437    #[test]
438    fn logical_or_adds_one_to_cc() {
439        // Kills: visit_expr_binary for || case
440        let f = write_temp("fn foo(a: bool, b: bool) -> bool { a || b }");
441        let fns = analyze_file(f.path()).expect("analyze");
442        assert_eq!(fns[0].cyclomatic, 2.0, "|| must add exactly 1 to base CC");
443    }
444
445    #[test]
446    fn bitwise_ops_do_not_increase_cc() {
447        // & and | are not control flow — they must NOT add to CC.
448        let f = write_temp("fn foo(a: u8, b: u8) -> u8 { a & b | a }");
449        let fns = analyze_file(f.path()).expect("analyze");
450        assert_eq!(fns[0].cyclomatic, 1.0, "bitwise ops must not affect CC");
451    }
452
453    #[test]
454    fn try_operator_adds_one_to_cc() {
455        // Kills: visit_expr_try replaced with (), += with -=, += with *=
456        let f = write_temp("fn foo() -> Option<i32> { let x: Option<i32> = Some(1); Some(x?) }");
457        let fns = analyze_file(f.path()).expect("analyze");
458        assert_eq!(
459            fns[0].cyclomatic, 2.0,
460            "? operator must add exactly 1 to base CC"
461        );
462    }
463
464    #[test]
465    fn closure_decisions_not_counted_in_enclosing_fn() {
466        // A closure with branches must not inflate the outer function's CC.
467        let f = write_temp("fn foo() -> i32 { let f = |x: i32| if x > 0 { x } else { -x }; f(1) }");
468        let fns = analyze_file(f.path()).expect("analyze");
469        assert_eq!(
470            fns[0].cyclomatic, 1.0,
471            "closure branches must not leak into outer CC"
472        );
473    }
474
475    #[test]
476    fn impl_methods_are_found() {
477        let f = write_temp(
478            r"
479struct Foo;
480impl Foo {
481    fn bar(&self) -> i32 { 1 }
482    fn baz(&self, x: i32) -> i32 {
483        if x > 0 { x } else { -x }
484    }
485}
486",
487        );
488        let fns = analyze_file(f.path()).expect("analyze");
489        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
490        assert!(
491            names.contains(&"Foo::bar"),
492            "expected Foo::bar, got {names:?}"
493        );
494        assert!(
495            names.contains(&"Foo::baz"),
496            "expected Foo::baz, got {names:?}"
497        );
498        let baz = fns.iter().find(|f| f.name == "Foo::baz").unwrap();
499        assert!(
500            baz.cyclomatic >= 2.0,
501            "baz should have CC >= 2, got {}",
502            baz.cyclomatic
503        );
504    }
505
506    // --- #[test] / #[cfg(test)] filtering ---
507
508    #[test]
509    fn test_functions_are_excluded() {
510        // Kills: removing the `has_attr(&node.attrs, "test")` early return.
511        let f = write_temp(
512            r"
513fn real() -> i32 { 42 }
514
515#[test]
516fn test_real() {
517    assert_eq!(real(), 42);
518}
519",
520        );
521        let fns = analyze_file(f.path()).expect("analyze");
522        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
523        assert!(names.contains(&"real"), "production fn must be present");
524        assert!(
525            !names.contains(&"test_real"),
526            "#[test] fn must be excluded, got: {names:?}"
527        );
528    }
529
530    #[test]
531    fn cfg_test_module_is_fully_excluded() {
532        // Kills: removing the visit_item_mod override (all three functions
533        // inside the module would otherwise appear).
534        let f = write_temp(
535            r"
536fn real() -> i32 { 42 }
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    fn helper(x: i32) -> i32 { x + 1 }
543
544    #[test]
545    fn test_real() {
546        assert_eq!(real(), 42);
547    }
548}
549",
550        );
551        let fns = analyze_file(f.path()).expect("analyze");
552        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
553        assert!(names.contains(&"real"), "production fn must be present");
554        assert!(
555            !names.contains(&"helper"),
556            "fn inside #[cfg(test)] mod must be excluded, got: {names:?}"
557        );
558        assert!(
559            !names.contains(&"test_real"),
560            "#[test] fn inside #[cfg(test)] mod must be excluded, got: {names:?}"
561        );
562    }
563
564    #[test]
565    fn non_cfg_test_module_functions_are_included() {
566        // Kills: replacing visit_item_mod with () — a no-op body would skip
567        // ALL module traversal, not just #[cfg(test)] ones.
568        // Also kills: replacing is_cfg_test with `true` — everything would
569        // look like a test module and be skipped.
570        let f = write_temp(
571            r"
572mod inner {
573    pub fn in_module() -> i32 { 1 }
574}
575",
576        );
577        let fns = analyze_file(f.path()).expect("analyze");
578        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
579        assert!(
580            names.contains(&"in_module"),
581            "fn inside a plain mod must be included, got: {names:?}"
582        );
583    }
584
585    #[test]
586    fn cfg_feature_module_is_not_skipped() {
587        // Kills: replacing `&&` with `||` in is_cfg_test — that mutation
588        // would make any `#[cfg(...)]` attribute look like #[cfg(test)],
589        // causing #[cfg(feature = "...")] modules to be wrongly excluded.
590        let f = write_temp(
591            r#"
592#[cfg(feature = "extra")]
593mod extra {
594    pub fn feature_fn() -> i32 { 1 }
595}
596"#,
597        );
598        let fns = analyze_file(f.path()).expect("analyze");
599        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
600        assert!(
601            names.contains(&"feature_fn"),
602            "#[cfg(feature = ...)] mod must not be skipped, got: {names:?}"
603        );
604    }
605
606    #[test]
607    fn only_test_attribute_is_filtered_not_other_attributes() {
608        // A fn with an unrelated attribute (#[allow(...)]) must NOT be excluded.
609        let f = write_temp(
610            r"
611#[allow(dead_code)]
612fn allowed() -> i32 { 42 }
613",
614        );
615        let fns = analyze_file(f.path()).expect("analyze");
616        let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
617        assert!(
618            names.contains(&"allowed"),
619            "#[allow(...)] fn must not be excluded, got: {names:?}"
620        );
621    }
622
623    // --- --exclude glob patterns ---
624
625    #[test]
626    fn analyze_tree_excludes_matching_files() {
627        use std::fs;
628        let dir = tempfile::tempdir().expect("tempdir");
629
630        // File that should be kept.
631        let src = dir.path().join("src");
632        fs::create_dir(&src).expect("mkdir src");
633        fs::write(src.join("lib.rs"), "fn kept() -> i32 { 42 }").expect("write lib.rs");
634
635        // File that should be excluded by the glob.
636        let generated = dir.path().join("generated");
637        fs::create_dir(&generated).expect("mkdir generated");
638        fs::write(generated.join("proto.rs"), "fn excluded() -> i32 { 1 }")
639            .expect("write proto.rs");
640
641        let results = analyze_tree(dir.path(), &["generated/**"]).expect("analyze_tree");
642        let names: Vec<_> = results.iter().map(|f| f.name.as_str()).collect();
643        assert!(names.contains(&"kept"), "src/lib.rs fn must appear");
644        assert!(
645            !names.contains(&"excluded"),
646            "generated/proto.rs fn must be excluded, got: {names:?}"
647        );
648    }
649
650    #[test]
651    fn analyze_tree_with_empty_excludes_keeps_all_files() {
652        // Kills: accidentally filtering everything when excludes is empty.
653        use std::fs;
654        let dir = tempfile::tempdir().expect("tempdir");
655        fs::write(dir.path().join("lib.rs"), "fn foo() -> i32 { 1 }").expect("write");
656
657        let results = analyze_tree(dir.path(), &[] as &[&str]).expect("analyze_tree");
658        assert!(!results.is_empty(), "no excludes must keep all files");
659    }
660
661    #[test]
662    fn invalid_exclude_pattern_returns_error() {
663        // Kills: silently ignoring invalid patterns.
664        let dir = tempfile::tempdir().expect("tempdir");
665        let result = analyze_tree(dir.path(), &["[invalid"]);
666        assert!(result.is_err(), "invalid glob must return an error");
667    }
668}