1use 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#[derive(Debug, Clone, Serialize)]
22pub struct FunctionComplexity {
23 pub file: PathBuf,
28 pub name: String,
30 pub start_line: usize,
32 pub end_line: usize,
34 pub cyclomatic: f64,
36}
37
38pub fn analyze_file(path: &Path) -> Result<Vec<FunctionComplexity>> {
44 let source = std::fs::read_to_string(path)
45 .with_context(|| format!("reading source file {}", path.display()))?;
46
47 let syntax = syn::parse_file(&source).with_context(|| format!("parsing {}", path.display()))?;
48
49 let mut visitor = FunctionVisitor {
50 file: path,
51 out: Vec::new(),
52 impl_type: None,
53 };
54 visitor.visit_file(&syntax);
55 Ok(visitor.out)
56}
57
58fn has_attr(
61 attrs: &[syn::Attribute],
62 name: &str,
63) -> bool {
64 attrs.iter().any(|a| a.path().is_ident(name))
65}
66
67fn is_cfg_test(attrs: &[syn::Attribute]) -> bool {
72 attrs.iter().any(|a| {
73 a.path().is_ident("cfg") && a.parse_args::<syn::Ident>().is_ok_and(|id| id == "test")
74 })
75}
76
77fn impl_type_name(ty: &syn::Type) -> Option<String> {
82 if let syn::Type::Path(tp) = ty {
83 tp.path.segments.last().map(|s| s.ident.to_string())
84 } else {
85 None
86 }
87}
88
89struct FunctionVisitor<'a> {
91 file: &'a Path,
92 out: Vec<FunctionComplexity>,
93 impl_type: Option<String>,
95}
96
97impl<'ast> Visit<'ast> for FunctionVisitor<'_> {
98 fn visit_item_fn(
99 &mut self,
100 node: &'ast ItemFn,
101 ) {
102 if has_attr(&node.attrs, "test") {
105 return;
106 }
107 let name = node.sig.ident.to_string();
108 let start_line = node.sig.fn_token.span.start().line;
109 let end_line = node.block.brace_token.span.close().end().line;
110 let cyclomatic = count_cyclomatic(&node.block) as f64;
111 self.out.push(FunctionComplexity {
112 file: self.file.to_path_buf(),
113 name,
114 start_line,
115 end_line,
116 cyclomatic,
117 });
118 }
120
121 fn visit_item_impl(
122 &mut self,
123 node: &'ast ItemImpl,
124 ) {
125 let prev = self.impl_type.take();
128 self.impl_type = impl_type_name(&node.self_ty);
129 visit::visit_item_impl(self, node);
130 self.impl_type = prev;
131 }
132
133 fn visit_impl_item_fn(
134 &mut self,
135 node: &'ast ImplItemFn,
136 ) {
137 if has_attr(&node.attrs, "test") {
138 return;
139 }
140 let method = node.sig.ident.to_string();
141 let name = match &self.impl_type {
142 Some(ty) => format!("{ty}::{method}"),
143 None => method,
144 };
145 let start_line = node.sig.fn_token.span.start().line;
146 let end_line = node.block.brace_token.span.close().end().line;
147 let cyclomatic = count_cyclomatic(&node.block) as f64;
148 self.out.push(FunctionComplexity {
149 file: self.file.to_path_buf(),
150 name,
151 start_line,
152 end_line,
153 cyclomatic,
154 });
155 }
156
157 fn visit_item_mod(
158 &mut self,
159 node: &'ast syn::ItemMod,
160 ) {
161 if !is_cfg_test(&node.attrs) {
164 visit::visit_item_mod(self, node);
165 }
166 }
167}
168
169fn count_cyclomatic(body: &syn::Block) -> usize {
173 let mut counter = CcCounter { count: 1 };
174 counter.visit_block(body);
175 counter.count
176}
177
178struct CcCounter {
180 count: usize,
181}
182
183impl<'ast> Visit<'ast> for CcCounter {
184 fn visit_expr_if(
185 &mut self,
186 node: &'ast syn::ExprIf,
187 ) {
188 self.count += 1;
189 visit::visit_expr_if(self, node); }
191
192 fn visit_expr_for_loop(
193 &mut self,
194 node: &'ast syn::ExprForLoop,
195 ) {
196 self.count += 1;
197 visit::visit_expr_for_loop(self, node);
198 }
199
200 fn visit_expr_while(
201 &mut self,
202 node: &'ast syn::ExprWhile,
203 ) {
204 self.count += 1;
205 visit::visit_expr_while(self, node);
206 }
207
208 fn visit_expr_loop(
209 &mut self,
210 node: &'ast syn::ExprLoop,
211 ) {
212 self.count += 1;
213 visit::visit_expr_loop(self, node);
214 }
215
216 fn visit_arm(
217 &mut self,
218 node: &'ast syn::Arm,
219 ) {
220 self.count += 1;
221 visit::visit_arm(self, node);
222 }
223
224 fn visit_expr_binary(
225 &mut self,
226 node: &'ast syn::ExprBinary,
227 ) {
228 if matches!(node.op, BinOp::And(_) | BinOp::Or(_)) {
229 self.count += 1;
230 }
231 visit::visit_expr_binary(self, node);
232 }
233
234 fn visit_expr_try(
235 &mut self,
236 node: &'ast syn::ExprTry,
237 ) {
238 self.count += 1;
239 visit::visit_expr_try(self, node);
240 }
241
242 fn visit_expr_closure(
243 &mut self,
244 _node: &'ast syn::ExprClosure,
245 ) {
246 }
249
250 fn visit_item(
251 &mut self,
252 _node: &'ast syn::Item,
253 ) {
254 }
261}
262
263fn build_exclude_set<S: AsRef<str>>(patterns: &[S]) -> Result<GlobSet> {
265 let mut builder = GlobSetBuilder::new();
266 for pat in patterns {
267 let glob = GlobBuilder::new(pat.as_ref())
268 .literal_separator(true) .build()
270 .with_context(|| format!("invalid exclude pattern: {:?}", pat.as_ref()))?;
271 builder.add(glob);
272 }
273 builder.build().context("building exclude glob set")
274}
275
276pub fn analyze_tree<S: AsRef<str>>(
285 root: &Path,
286 excludes: &[S],
287) -> Result<Vec<FunctionComplexity>> {
288 let exclude_set = build_exclude_set(excludes)?;
289
290 let paths: Vec<PathBuf> = {
293 let walker = ignore::WalkBuilder::new(root)
294 .standard_filters(true)
295 .build();
296
297 walker
298 .filter_map(|result| {
299 let entry = match result {
300 Ok(e) => e,
301 Err(err) => {
302 eprintln!("warning: walk error: {err}");
303 return None;
304 },
305 };
306 if !entry.file_type().is_some_and(|t| t.is_file()) {
307 return None;
308 }
309 if entry.path().extension().and_then(|e| e.to_str()) != Some("rs") {
310 return None;
311 }
312 if !exclude_set.is_empty()
313 && let Ok(rel) = entry.path().strip_prefix(root)
314 && exclude_set.is_match(rel)
315 {
316 return None;
317 }
318 Some(entry.path().to_path_buf())
319 })
320 .collect()
321 };
322
323 let all: Vec<FunctionComplexity> = paths
326 .par_iter()
327 .flat_map_iter(|path| match analyze_file(path) {
328 Ok(fns) => fns,
329 Err(err) => {
330 eprintln!("warning: could not analyze {}: {err}", path.display());
331 vec![]
332 },
333 })
334 .collect();
335
336 Ok(all)
337}
338
339#[cfg(test)]
340#[expect(
341 clippy::float_cmp,
342 reason = "CC counter increments by integer steps stored as f64; exact equality is the right comparison"
343)]
344mod tests {
345 use super::*;
346 use std::io::Write;
347
348 fn write_temp(source: &str) -> tempfile::NamedTempFile {
349 let mut f = tempfile::Builder::new()
350 .suffix(".rs")
351 .tempfile()
352 .expect("tempfile");
353 f.write_all(source.as_bytes()).expect("write");
354 f
355 }
356
357 #[test]
358 fn trivial_function_has_cc_one() {
359 let f = write_temp("fn hello() -> i32 { 42 }");
360 let fns = analyze_file(f.path()).expect("analyze");
361 assert_eq!(fns.len(), 1);
362 assert_eq!(fns[0].name, "hello");
363 assert_eq!(fns[0].cyclomatic, 1.0);
364 }
365
366 #[test]
367 fn branching_increases_cc() {
368 let f = write_temp(
369 r#"
370fn check(x: i32) -> &'static str {
371 if x < 0 {
372 "neg"
373 } else if x == 0 {
374 "zero"
375 } else {
376 "pos"
377 }
378}
379"#,
380 );
381 let fns = analyze_file(f.path()).expect("analyze");
382 assert_eq!(fns.len(), 1);
383 assert!(
384 fns[0].cyclomatic >= 3.0,
385 "expected CC ≥ 3 for two-branch if/else, got {}",
386 fns[0].cyclomatic
387 );
388 }
389
390 #[test]
391 fn nested_fn_does_not_inflate_enclosing_cc() {
392 let f = write_temp(
395 r"
396fn outer() -> i32 {
397 fn inner(y: i32) -> i32 {
398 if y > 0 { y } else { -y }
399 }
400 inner(1)
401}
402",
403 );
404 let fns = analyze_file(f.path()).expect("analyze");
405 assert_eq!(fns.len(), 1, "nested fns are not extracted as entries");
406 assert_eq!(fns[0].name, "outer");
407 assert_eq!(
408 fns[0].cyclomatic, 1.0,
409 "inner's `if` must not count toward outer"
410 );
411 }
412
413 #[test]
414 fn nested_impl_and_mod_do_not_inflate_enclosing_cc() {
415 let f = write_temp(
416 r"
417fn outer() -> u32 {
418 struct S;
419 impl S {
420 fn branchy(x: u32) -> u32 {
421 match x {
422 0 => 1,
423 1 => 2,
424 _ => 3,
425 }
426 }
427 }
428 mod local {
429 pub fn helper(b: bool) -> bool {
430 b && !b || b
431 }
432 }
433 S::branchy(local::helper(true) as u32)
434}
435",
436 );
437 let fns = analyze_file(f.path()).expect("analyze");
438 assert_eq!(fns.len(), 1);
439 assert_eq!(
440 fns[0].cyclomatic, 1.0,
441 "match arms and boolean operators inside nested impl/mod items \
442 must not count toward outer"
443 );
444 }
445
446 #[test]
447 fn code_after_a_nested_item_still_counts() {
448 let f = write_temp(
451 r"
452fn outer(x: i32) -> i32 {
453 fn inner() -> i32 { 1 }
454 if x > 0 { inner() } else { 0 }
455}
456",
457 );
458 let fns = analyze_file(f.path()).expect("analyze");
459 assert_eq!(fns.len(), 1);
460 assert_eq!(
461 fns[0].cyclomatic, 2.0,
462 "outer's own `if` after the nested item must still count"
463 );
464 }
465
466 #[test]
467 fn multiple_functions_are_all_found() {
468 let f = write_temp(
469 r"
470fn a() {}
471fn b() {}
472fn c() {}
473",
474 );
475 let fns = analyze_file(f.path()).expect("analyze");
476 let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
477 assert!(names.contains(&"a"));
478 assert!(names.contains(&"b"));
479 assert!(names.contains(&"c"));
480 }
481
482 #[test]
483 fn for_loop_adds_one_to_cc() {
484 let f = write_temp("fn foo(n: i32) -> i32 { let mut s = 0; for _i in 0..n { s += 1; } s }");
486 let fns = analyze_file(f.path()).expect("analyze");
487 assert_eq!(
488 fns[0].cyclomatic, 2.0,
489 "for loop must add exactly 1 to base CC"
490 );
491 }
492
493 #[test]
494 fn while_loop_adds_one_to_cc() {
495 let f = write_temp("fn foo(mut n: i32) -> i32 { while n > 0 { n -= 1; } n }");
497 let fns = analyze_file(f.path()).expect("analyze");
498 assert_eq!(
499 fns[0].cyclomatic, 2.0,
500 "while loop must add exactly 1 to base CC"
501 );
502 }
503
504 #[test]
505 fn loop_expr_adds_one_to_cc() {
506 let f = write_temp("fn foo() { loop { break; } }");
508 let fns = analyze_file(f.path()).expect("analyze");
509 assert_eq!(fns[0].cyclomatic, 2.0, "loop must add exactly 1 to base CC");
510 }
511
512 #[test]
513 fn match_arms_each_add_one_to_cc() {
514 let f = write_temp("fn foo(x: u8) -> u8 { match x { 0 => 1, 1 => 2, _ => 3 } }");
516 let fns = analyze_file(f.path()).expect("analyze");
517 assert_eq!(fns[0].cyclomatic, 4.0, "3-arm match must add 3 to base CC");
518 }
519
520 #[test]
521 fn logical_and_adds_one_to_cc() {
522 let f = write_temp("fn foo(a: bool, b: bool) -> bool { a && b }");
524 let fns = analyze_file(f.path()).expect("analyze");
525 assert_eq!(fns[0].cyclomatic, 2.0, "&& must add exactly 1 to base CC");
526 }
527
528 #[test]
529 fn logical_or_adds_one_to_cc() {
530 let f = write_temp("fn foo(a: bool, b: bool) -> bool { a || b }");
532 let fns = analyze_file(f.path()).expect("analyze");
533 assert_eq!(fns[0].cyclomatic, 2.0, "|| must add exactly 1 to base CC");
534 }
535
536 #[test]
537 fn bitwise_ops_do_not_increase_cc() {
538 let f = write_temp("fn foo(a: u8, b: u8) -> u8 { a & b | a }");
540 let fns = analyze_file(f.path()).expect("analyze");
541 assert_eq!(fns[0].cyclomatic, 1.0, "bitwise ops must not affect CC");
542 }
543
544 #[test]
545 fn try_operator_adds_one_to_cc() {
546 let f = write_temp("fn foo() -> Option<i32> { let x: Option<i32> = Some(1); Some(x?) }");
548 let fns = analyze_file(f.path()).expect("analyze");
549 assert_eq!(
550 fns[0].cyclomatic, 2.0,
551 "? operator must add exactly 1 to base CC"
552 );
553 }
554
555 #[test]
556 fn closure_decisions_not_counted_in_enclosing_fn() {
557 let f = write_temp("fn foo() -> i32 { let f = |x: i32| if x > 0 { x } else { -x }; f(1) }");
559 let fns = analyze_file(f.path()).expect("analyze");
560 assert_eq!(
561 fns[0].cyclomatic, 1.0,
562 "closure branches must not leak into outer CC"
563 );
564 }
565
566 #[test]
567 fn impl_methods_are_found() {
568 let f = write_temp(
569 r"
570struct Foo;
571impl Foo {
572 fn bar(&self) -> i32 { 1 }
573 fn baz(&self, x: i32) -> i32 {
574 if x > 0 { x } else { -x }
575 }
576}
577",
578 );
579 let fns = analyze_file(f.path()).expect("analyze");
580 let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
581 assert!(
582 names.contains(&"Foo::bar"),
583 "expected Foo::bar, got {names:?}"
584 );
585 assert!(
586 names.contains(&"Foo::baz"),
587 "expected Foo::baz, got {names:?}"
588 );
589 let baz = fns.iter().find(|f| f.name == "Foo::baz").unwrap();
590 assert!(
591 baz.cyclomatic >= 2.0,
592 "baz should have CC >= 2, got {}",
593 baz.cyclomatic
594 );
595 }
596
597 #[test]
600 fn test_functions_are_excluded() {
601 let f = write_temp(
603 r"
604fn real() -> i32 { 42 }
605
606#[test]
607fn test_real() {
608 assert_eq!(real(), 42);
609}
610",
611 );
612 let fns = analyze_file(f.path()).expect("analyze");
613 let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
614 assert!(names.contains(&"real"), "production fn must be present");
615 assert!(
616 !names.contains(&"test_real"),
617 "#[test] fn must be excluded, got: {names:?}"
618 );
619 }
620
621 #[test]
622 fn cfg_test_module_is_fully_excluded() {
623 let f = write_temp(
626 r"
627fn real() -> i32 { 42 }
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 fn helper(x: i32) -> i32 { x + 1 }
634
635 #[test]
636 fn test_real() {
637 assert_eq!(real(), 42);
638 }
639}
640",
641 );
642 let fns = analyze_file(f.path()).expect("analyze");
643 let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
644 assert!(names.contains(&"real"), "production fn must be present");
645 assert!(
646 !names.contains(&"helper"),
647 "fn inside #[cfg(test)] mod must be excluded, got: {names:?}"
648 );
649 assert!(
650 !names.contains(&"test_real"),
651 "#[test] fn inside #[cfg(test)] mod must be excluded, got: {names:?}"
652 );
653 }
654
655 #[test]
656 fn non_cfg_test_module_functions_are_included() {
657 let f = write_temp(
662 r"
663mod inner {
664 pub fn in_module() -> i32 { 1 }
665}
666",
667 );
668 let fns = analyze_file(f.path()).expect("analyze");
669 let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
670 assert!(
671 names.contains(&"in_module"),
672 "fn inside a plain mod must be included, got: {names:?}"
673 );
674 }
675
676 #[test]
677 fn cfg_feature_module_is_not_skipped() {
678 let f = write_temp(
682 r#"
683#[cfg(feature = "extra")]
684mod extra {
685 pub fn feature_fn() -> i32 { 1 }
686}
687"#,
688 );
689 let fns = analyze_file(f.path()).expect("analyze");
690 let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
691 assert!(
692 names.contains(&"feature_fn"),
693 "#[cfg(feature = ...)] mod must not be skipped, got: {names:?}"
694 );
695 }
696
697 #[test]
698 fn only_test_attribute_is_filtered_not_other_attributes() {
699 let f = write_temp(
701 r"
702#[allow(dead_code)]
703fn allowed() -> i32 { 42 }
704",
705 );
706 let fns = analyze_file(f.path()).expect("analyze");
707 let names: Vec<_> = fns.iter().map(|fc| fc.name.as_str()).collect();
708 assert!(
709 names.contains(&"allowed"),
710 "#[allow(...)] fn must not be excluded, got: {names:?}"
711 );
712 }
713
714 #[test]
717 fn analyze_tree_excludes_matching_files() {
718 use std::fs;
719 let dir = tempfile::tempdir().expect("tempdir");
720
721 let src = dir.path().join("src");
723 fs::create_dir(&src).expect("mkdir src");
724 fs::write(src.join("lib.rs"), "fn kept() -> i32 { 42 }").expect("write lib.rs");
725
726 let generated = dir.path().join("generated");
728 fs::create_dir(&generated).expect("mkdir generated");
729 fs::write(generated.join("proto.rs"), "fn excluded() -> i32 { 1 }")
730 .expect("write proto.rs");
731
732 let results = analyze_tree(dir.path(), &["generated/**"]).expect("analyze_tree");
733 let names: Vec<_> = results.iter().map(|f| f.name.as_str()).collect();
734 assert!(names.contains(&"kept"), "src/lib.rs fn must appear");
735 assert!(
736 !names.contains(&"excluded"),
737 "generated/proto.rs fn must be excluded, got: {names:?}"
738 );
739 }
740
741 #[test]
742 fn analyze_tree_with_empty_excludes_keeps_all_files() {
743 use std::fs;
745 let dir = tempfile::tempdir().expect("tempdir");
746 fs::write(dir.path().join("lib.rs"), "fn foo() -> i32 { 1 }").expect("write");
747
748 let results = analyze_tree(dir.path(), &[] as &[&str]).expect("analyze_tree");
749 assert!(!results.is_empty(), "no excludes must keep all files");
750 }
751
752 #[test]
753 fn invalid_exclude_pattern_returns_error() {
754 let dir = tempfile::tempdir().expect("tempdir");
756 let result = analyze_tree(dir.path(), &["[invalid"]);
757 assert!(result.is_err(), "invalid glob must return an error");
758 }
759}