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,
25 pub name: String,
27 pub start_line: usize,
29 pub end_line: usize,
31 pub cyclomatic: f64,
33}
34
35pub 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
55fn has_attr(
58 attrs: &[syn::Attribute],
59 name: &str,
60) -> bool {
61 attrs.iter().any(|a| a.path().is_ident(name))
62}
63
64fn 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
74fn 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
86struct FunctionVisitor<'a> {
88 file: &'a Path,
89 out: Vec<FunctionComplexity>,
90 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 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 }
117
118 fn visit_item_impl(
119 &mut self,
120 node: &'ast ItemImpl,
121 ) {
122 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 if !is_cfg_test(&node.attrs) {
161 visit::visit_item_mod(self, node);
162 }
163 }
164}
165
166fn count_cyclomatic(body: &syn::Block) -> usize {
170 let mut counter = CcCounter { count: 1 };
171 counter.visit_block(body);
172 counter.count
173}
174
175struct 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); }
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 }
246}
247
248fn 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) .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
261pub 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 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 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 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 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 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 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 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 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 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 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 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]
509 fn test_functions_are_excluded() {
510 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 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 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 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 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 #[test]
626 fn analyze_tree_excludes_matching_files() {
627 use std::fs;
628 let dir = tempfile::tempdir().expect("tempdir");
629
630 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 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 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 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}