Skip to content

Commit e440a16

Browse files
GotenJBZvineethk
andauthored
[lint] cyclomatic_complexity (#17170)
* lint: cyclomatic_complexity * add more tests, fix bugs with while and for * mv cyclomatic_complexity into experimental * Update third_party/move/tools/move-linter/src/model_ast_lints/cyclomatic_complexity.rs Co-authored-by: Vineeth Kashyap <[email protected]> * suppress complexity warning in test * addressing comments * UPBL * fixing docs * UPBL --------- Co-authored-by: Vineeth Kashyap <[email protected]>
1 parent f386807 commit e440a16

12 files changed

+1993
-105
lines changed

third_party/move/tools/move-linter/src/model_ast_lints.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod aborting_overflow_checks;
77
mod almost_swapped;
88
mod assert_const;
99
mod blocks_in_conditions;
10+
mod cyclomatic_complexity;
1011
mod equal_operands_in_bin_op;
1112
mod known_to_abort;
1213
mod needless_bool;
@@ -27,7 +28,7 @@ use std::collections::BTreeMap;
2728
/// Returns a default pipeline of "expression linters" to run.
2829
pub fn get_default_linter_pipeline(config: &BTreeMap<String, String>) -> Vec<Box<dyn ExpChecker>> {
2930
// Start with the default set of checks.
30-
let checks: Vec<Box<dyn ExpChecker>> = vec![
31+
let mut checks: Vec<Box<dyn ExpChecker>> = vec![
3132
Box::<aborting_overflow_checks::AbortingOverflowChecks>::default(),
3233
Box::<almost_swapped::AlmostSwapped>::default(),
3334
Box::<assert_const::AssertConst>::default(),
@@ -52,6 +53,7 @@ pub fn get_default_linter_pipeline(config: &BTreeMap<String, String>) -> Vec<Box
5253
}
5354
if checks_category == "experimental" {
5455
// Push experimental checks to `checks`.
56+
checks.push(Box::<cyclomatic_complexity::CyclomaticComplexity>::default());
5557
}
5658
checks
5759
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) Aptos Foundation
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Cyclomatic complexity measures the number of linearly independent execution paths
5+
//! through a function. A high value generally correlates with code that is
6+
//! harder to test and maintain.
7+
//!
8+
//! This linter performs an approximation while traversing the Move
9+
//! expression tree:
10+
//! 1. The complexity score starts at **1**.
11+
//! 2. The score is incremented for each control-flow decision point found:
12+
//! * +1 for each `if`
13+
//! * +1 for each `else if`
14+
//! * +1 for each `loop`, `while`, or `for`
15+
//! * +1 for each `break` or `continue`
16+
//! * +1 for each `return` statement that is not the final expression in the function
17+
//! * +n where n = (number of match arms - 1)
18+
//!
19+
//! When the accumulated score exceeds `DEFAULT_THRESHOLD` (currently **10**),
20+
//! the linter emits a diagnostic suggesting that the function be simplified or
21+
//! decomposed.
22+
//!
23+
//! NOTE: The threshold is intentionally conservative.
24+
25+
use crate::utils::{detect_for_loop, detect_while_loop};
26+
use move_compiler_v2::external_checks::ExpChecker;
27+
use move_model::{
28+
ast::ExpData,
29+
model::{FunctionEnv, Loc, NodeId},
30+
};
31+
32+
const DEFAULT_THRESHOLD: i32 = 10;
33+
34+
pub struct CyclomaticComplexity {
35+
complexity: i32,
36+
reported: bool,
37+
root_node: Option<NodeId>,
38+
final_return_node: Option<NodeId>,
39+
}
40+
41+
impl CyclomaticComplexity {
42+
fn bump(&mut self, delta: i32) {
43+
self.complexity = self.complexity.saturating_add(delta);
44+
}
45+
46+
/// Returns the NodeId of the final return statement in the function, if any.
47+
fn get_final_return(function: &FunctionEnv) -> Option<NodeId> {
48+
if let Some(def) = function.get_def() {
49+
return Self::get_final_return_from_exp(def);
50+
}
51+
None
52+
}
53+
54+
/// Helper method to recursively find the final return statement in an expression.
55+
fn get_final_return_from_exp(expr: &move_model::ast::Exp) -> Option<NodeId> {
56+
use move_model::ast::ExpData::*;
57+
match expr.as_ref() {
58+
Return(id, _) => Some(*id),
59+
Sequence(_, seq) => seq.last().and_then(Self::get_final_return_from_exp),
60+
Block(_, _, _, body) => Self::get_final_return_from_exp(body),
61+
_ => None,
62+
}
63+
}
64+
65+
fn maybe_report(&mut self, function: &FunctionEnv) {
66+
if self.reported || self.complexity <= DEFAULT_THRESHOLD {
67+
return;
68+
}
69+
let env = function.env();
70+
let loc: Loc = function.get_loc();
71+
self.reported = true;
72+
self.report(
73+
env,
74+
&loc,
75+
&format!(
76+
"Function `{}` has cyclomatic complexity {}, which exceeds the allowed threshold of {}",
77+
function.get_full_name_str(),
78+
self.complexity,
79+
DEFAULT_THRESHOLD,
80+
),
81+
);
82+
}
83+
}
84+
85+
impl Default for CyclomaticComplexity {
86+
fn default() -> Self {
87+
Self {
88+
complexity: 1,
89+
reported: false,
90+
root_node: None,
91+
final_return_node: None,
92+
}
93+
}
94+
}
95+
96+
impl ExpChecker for CyclomaticComplexity {
97+
fn get_name(&self) -> String {
98+
"cyclomatic_complexity".to_string()
99+
}
100+
101+
fn visit_expr_pre(&mut self, function: &FunctionEnv, expr: &ExpData) {
102+
if self.root_node.is_none() {
103+
self.root_node = function.get_def().map(|def| def.node_id());
104+
self.final_return_node = Self::get_final_return(function);
105+
}
106+
107+
use ExpData::*;
108+
109+
match expr {
110+
// loop, while, for
111+
Loop(_, _) => {
112+
let delta = if detect_for_loop(expr, function) {
113+
// For loop expansion generates: Loop(+1) + IfElse(+1) + IfElse(+1) + IfElse(+1) + LoopCont(+1) + LoopCont(+1) = +6 extra
114+
// But we want for to count as +1 total, so we subtract 4 here
115+
-4
116+
} else if detect_while_loop(expr) {
117+
// While loop expansion generates: Loop(+1) + IfElse(+1) + LoopCont(+1) = +3 extra
118+
// But we want while to count as +1 total, so we subtract 1 here
119+
-1
120+
} else {
121+
1
122+
};
123+
self.bump(delta);
124+
},
125+
// if and else if (+1)
126+
IfElse(..) => self.bump(1),
127+
// break and continue (+1)
128+
LoopCont(..) => self.bump(1),
129+
130+
// return, if is not the last statement (+1)
131+
Return(_, _) => {
132+
if self.final_return_node != Some(expr.node_id()) {
133+
self.bump(1);
134+
}
135+
},
136+
// match (+n-1)
137+
Match(_, _, arms) if !arms.is_empty() => self.bump(arms.len() as i32 - 1),
138+
139+
_ => {},
140+
}
141+
}
142+
143+
fn visit_expr_post(&mut self, function: &FunctionEnv, expr: &ExpData) {
144+
if let Some(root) = self.root_node {
145+
if expr.node_id() == root {
146+
self.maybe_report(function);
147+
}
148+
}
149+
}
150+
}

third_party/move/tools/move-linter/src/model_ast_lints/while_true.rs

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44
//! This module implements an expression linter that checks code of the form:
55
//! `while (true) { ... }` and suggests to use `loop { ... }` instead.
66
7-
use legacy_move_compiler::parser::syntax::FOR_LOOP_UPDATE_ITER_FLAG;
7+
use crate::utils::detect_for_loop;
88
use move_compiler_v2::external_checks::ExpChecker;
99
use move_model::{
10-
ast::{Exp, ExpData, Value},
10+
ast::{ExpData, Value},
1111
model::FunctionEnv,
1212
};
1313

@@ -23,7 +23,7 @@ impl ExpChecker for WhileTrue {
2323
use ExpData::{IfElse, Loop};
2424
// Check if `expr` is of the form: Loop(IfElse(true, then, _)).
2525
let Loop(id, body) = expr else { return };
26-
let IfElse(_, cond, then, _) = body.as_ref() else {
26+
let IfElse(_, cond, _, _) = body.as_ref() else {
2727
return;
2828
};
2929
let ExpData::Value(_, Value::Bool(b)) = cond.as_ref() else {
@@ -33,7 +33,7 @@ impl ExpChecker for WhileTrue {
3333
return;
3434
}
3535
// Check if it is the `for` loop.
36-
if detect_for_loop(then, function) {
36+
if detect_for_loop(expr, function) {
3737
return;
3838
}
3939
let env = function.env();
@@ -45,23 +45,3 @@ impl ExpChecker for WhileTrue {
4545
);
4646
}
4747
}
48-
49-
fn detect_for_loop(then: &Exp, function: &FunctionEnv) -> bool {
50-
use ExpData::{IfElse, LocalVar, Sequence};
51-
// Check if `then` is of the form:
52-
// Sequence([IfElse(LocalVar(FOR_LOOP_UPDATE_ITER_FLAG), ...)])
53-
// If so, it is the `for` loop.
54-
let Sequence(_, stmts) = then.as_ref() else {
55-
return false;
56-
};
57-
let Some(stmt) = stmts.first() else {
58-
return false;
59-
};
60-
let IfElse(_, cond, _, _) = stmt.as_ref() else {
61-
return false;
62-
};
63-
let LocalVar(_, name) = cond.as_ref() else {
64-
return false;
65-
};
66-
name.display(function.symbol_pool()).to_string() == FOR_LOOP_UPDATE_ITER_FLAG
67-
}

third_party/move/tools/move-linter/src/utils.rs

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
//! This module holds utility functions for the Move linter.
5-
6-
use move_model::ast::{ExpData, Operation};
5+
use legacy_move_compiler::parser::syntax::FOR_LOOP_UPDATE_ITER_FLAG;
6+
use move_model::{
7+
ast::{
8+
ExpData,
9+
ExpData::{IfElse, LocalVar, Loop, Sequence},
10+
Operation,
11+
},
12+
model::FunctionEnv,
13+
};
714

815
/// Returns `true` if two expressions represent the same simple access pattern.
916
/// This compares nested `Select`, `Borrow`, and local variable references for structural equality.
@@ -33,3 +40,69 @@ pub(crate) fn is_simple_access_equal(expr1: &ExpData, expr2: &ExpData) -> bool {
3340
_ => false,
3441
}
3542
}
43+
44+
/// Detects if the given Loop expression matches the pattern of a while loop.
45+
/// ```ignore
46+
/// loop {
47+
/// if (condition) {
48+
/// // loop body
49+
/// } else {
50+
/// break;
51+
/// }
52+
/// }
53+
/// ```
54+
///
55+
pub(crate) fn detect_while_loop(expr: &ExpData) -> bool {
56+
let Loop(_, loop_body) = expr else {
57+
return false;
58+
};
59+
match loop_body.as_ref() {
60+
ExpData::IfElse(_, _, _, else_expr) => {
61+
matches!(else_expr.as_ref(), ExpData::LoopCont(_, nest, is_continue) if *nest == 0 && !*is_continue)
62+
},
63+
_ => false,
64+
}
65+
}
66+
67+
/// Detects if the Loop expression matches the pattern of an expanded for loop.
68+
///
69+
/// The expanded for loop has this structure:
70+
/// ```ignore
71+
/// loop {
72+
/// if (true) {
73+
/// if (flag) {
74+
/// increment;
75+
/// } else {
76+
/// flag = true;
77+
/// }
78+
/// if (i < limit) {
79+
/// body;
80+
/// } else {
81+
/// break;
82+
/// }
83+
/// } else {
84+
/// break;
85+
/// }
86+
/// }
87+
/// ```
88+
pub(crate) fn detect_for_loop(expr: &ExpData, function: &FunctionEnv) -> bool {
89+
let Loop(_, body) = expr else { return false };
90+
91+
let IfElse(_, _, then, _) = body.as_ref() else {
92+
return false;
93+
};
94+
95+
let Sequence(_, stmts) = then.as_ref() else {
96+
return false;
97+
};
98+
let Some(stmt) = stmts.first() else {
99+
return false;
100+
};
101+
let IfElse(_, cond, _, _) = stmt.as_ref() else {
102+
return false;
103+
};
104+
let LocalVar(_, name) = cond.as_ref() else {
105+
return false;
106+
};
107+
name.display(function.symbol_pool()).to_string() == FOR_LOOP_UPDATE_ITER_FLAG
108+
}

0 commit comments

Comments
 (0)