Skip to content

Commit 0daea9c

Browse files
davidtgillardknocte
authored andcommitted
fix cyclomatic complexity yielding redundant messages
- Issue #559: fix for cyclomatic complexity rule yielding multiple messages for same scope - Added tests for this case
1 parent 14fe636 commit 0daea9c

File tree

2 files changed

+108
-55
lines changed

2 files changed

+108
-55
lines changed

src/FSharpLint.Core/Rules/Conventions/CyclomaticComplexity.fs

Lines changed: 88 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
module FSharpLint.Rules.CyclomaticComplexity
22

33
open System
4-
open System.IO
4+
open System.Collections.Generic
5+
open System.Linq
56
open FSharp.Compiler.Syntax
67
open FSharpLint.Framework
78
open FSharpLint.Framework.Suggestion
@@ -16,7 +17,7 @@ type Config = {
1617
}
1718

1819
/// The scope of a binding.
19-
type BindingScope =
20+
type private BindingScope =
2021
{
2122
/// The Node corresponding to the binding.
2223
Node: AstNode
@@ -26,8 +27,68 @@ type BindingScope =
2627
Complexity: int
2728
}
2829

30+
type private BindingScopeComparer() =
31+
interface IComparer<BindingScope> with
32+
member this.Compare(left, right) =
33+
let leftStart = left.Binding.RangeOfBindingWithoutRhs.Start
34+
let rightStart = right.Binding.RangeOfBindingWithoutRhs.Start
35+
let leftTuple : ValueTuple<int, int, int> = leftStart.Line, leftStart.Column, left.Complexity
36+
let rightTuple : ValueTuple<int, int, int> = rightStart.Line, rightStart.Column, right.Complexity
37+
leftTuple.CompareTo rightTuple
38+
39+
/// A two-tiered stack-like structure for containing BindingScopes.
40+
type private BindingStack(maxComplexity: int) =
41+
let mutable tier1_ = []
42+
let mutable tier2_ = SortedSet<BindingScope>(BindingScopeComparer())
43+
44+
member this.Push (args:AstNodeRuleParams) (bs: BindingScope) =
45+
// if the node is a child of the current binding scope
46+
let isChildOfCurrent = if List.isEmpty tier1_ then
47+
false
48+
else
49+
args.GetParents args.NodeIndex |> List.tryFind (fun x -> Object.ReferenceEquals(tier1_.Head.Node, x)) |> Option.isSome
50+
// if the node is not a child and the stack isn't empty, we're finished with the current head of tier1, so move it from tier1 to tier2
51+
if not isChildOfCurrent && not (List.isEmpty tier1_) then
52+
let popped = tier1_.Head
53+
tier1_ <- tier1_.Tail
54+
if popped.Complexity > maxComplexity then
55+
tier2_.Add popped |> ignore
56+
// finally, push the item on to the stack
57+
tier1_ <- bs::tier1_
58+
59+
member this.IncrComplexityOfCurrentScope incr =
60+
let h = tier1_.Head
61+
let complexity = h.Complexity + incr
62+
tier1_ <- {h with Complexity = complexity}::tier1_.Tail
63+
64+
interface IEnumerable<BindingScope> with
65+
member this.GetEnumerator() =
66+
let cleanedUp =
67+
tier1_
68+
|> List.filter (fun scope -> scope.Complexity > maxComplexity)
69+
|> List.sortByDescending (fun scope -> scope.Complexity) // sort in descending order by complexity
70+
|> List.distinctBy (fun scope -> scope.Binding.RangeOfBindingWithRhs.Start) // throw away any extraneous elements with the same start position but a lower complexity
71+
let enum1 = cleanedUp :> IEnumerable<BindingScope>
72+
let enum2 = tier2_ :> IEnumerable<BindingScope>
73+
Enumerable.Concat(enum1, enum2).GetEnumerator()
74+
75+
member this.GetEnumerator(): Collections.IEnumerator = (this :> IEnumerable<BindingScope> :> System.Collections.IEnumerable).GetEnumerator()
76+
77+
/// Clears the stack.
78+
member this.Clear() =
79+
tier1_ <- []
80+
tier2_.Clear()
81+
2982
/// A stack to track the current cyclomatic complexity of a binding scope.
30-
let mutable private BindingStack : BindingScope list = []
83+
let mutable private bindingStackOpt : BindingStack option = None
84+
85+
/// gets the global binding stack
86+
let private getBindingStack (maxComplexity: int) =
87+
match bindingStackOpt with
88+
| Some bs -> bs
89+
| None -> let bs = BindingStack maxComplexity
90+
bindingStackOpt <- Some bs
91+
bs
3192

3293
/// Determines the number of cases in a match clause.
3394
let private countCasesInMatchClause (clause: SynMatchClause) =
@@ -42,11 +103,6 @@ let private countCasesInMatchClause (clause: SynMatchClause) =
42103
match clause with
43104
| SynMatchClause(pat, _, _, _, _) -> countCases pat 0
44105

45-
let private increaseComplexity incr =
46-
let h = BindingStack.Head
47-
let complexity = h.Complexity + incr
48-
BindingStack <- {h with Complexity = complexity}::BindingStack
49-
50106
/// Boolean operator functions.
51107
let private boolFunctions = Set.ofList ["op_BooleanOr"; "op_BooleanAnd"]
52108

@@ -89,20 +145,8 @@ let private countBooleanOperators expression =
89145

90146
/// Runner for the rule.
91147
let runner (config:Config) (args:AstNodeRuleParams) : WarningDetails[] =
92-
let popOffBindingStack() =
93-
if BindingStack.Length > 0 then
94-
let popped = BindingStack.Head
95-
BindingStack <- BindingStack.Tail
96-
// if the complexity within the scope of the binding is greater than the max complexity, report it
97-
if popped.Complexity > config.MaxComplexity then
98-
let errorFormatString = Resources.GetString("RulesCyclomaticComplexityError")
99-
let errMsg = String.Format(errorFormatString, popped.Complexity, config.MaxComplexity)
100-
Some { Range = popped.Binding.RangeOfBindingWithRhs; Message = errMsg; SuggestedFix = None; TypeChecks = [] }
101-
else
102-
None
103-
else
104-
None
105-
148+
let bindingStack = getBindingStack config.MaxComplexity
149+
106150
let mutable warningDetails = None
107151
let node = args.AstNode
108152
let parentIndex = args.SyntaxArray.[args.NodeIndex].ParentIndex
@@ -115,18 +159,10 @@ let runner (config:Config) (args:AstNodeRuleParams) : WarningDetails[] =
115159
let bindingOpt = match node with
116160
| AstNode.Binding binding -> Some binding
117161
| _ -> None
118-
// if the node is a child of the current binding scope
119-
let isChildOfCurrent = if List.isEmpty BindingStack then
120-
false
121-
else
122-
args.GetParents args.NodeIndex |> List.tryFind (fun x -> Object.ReferenceEquals(BindingStack.Head.Node, x)) |> Option.isSome
123-
// if the node is not a child and the stack isn't empty, we're finished with the current binding scope at the head of the stack, so pop it off
124-
if not isChildOfCurrent && List.length BindingStack > 0 then
125-
warningDetails <- popOffBindingStack()
126162

127163
// if the node is a binding, push it onto the stack
128164
match bindingOpt with
129-
| Some binding -> BindingStack <- { Node = node; Binding = binding; Complexity = 0 }::BindingStack
165+
| Some binding -> bindingStack.Push args { Node = node; Binding = binding; Complexity = 0 }
130166
| None -> ()
131167

132168
// if not metadata, match the node against an expression which increments the complexity
@@ -135,40 +171,43 @@ let runner (config:Config) (args:AstNodeRuleParams) : WarningDetails[] =
135171
| AstNode.Expression expression ->
136172
match expression with
137173
| SynExpr.For _ ->
138-
increaseComplexity 1
174+
bindingStack.IncrComplexityOfCurrentScope 1
139175
| SynExpr.ForEach _ ->
140-
increaseComplexity 1
176+
bindingStack.IncrComplexityOfCurrentScope 1
141177
| SynExpr.While(_, condition, _, _) ->
142-
increaseComplexity (1 + countBooleanOperators condition) // include the number of boolean operators in the while condition
178+
bindingStack.IncrComplexityOfCurrentScope (1 + countBooleanOperators condition) // include the number of boolean operators in the while condition
143179
| SynExpr.IfThenElse(condition, _, _, _, _, _, _) ->
144-
increaseComplexity (1 + countBooleanOperators condition) // include the number of boolean operators in the condition
180+
bindingStack.IncrComplexityOfCurrentScope (1 + countBooleanOperators condition) // include the number of boolean operators in the condition
145181
| SynExpr.MatchBang(_, _, clauses, _)
146182
| SynExpr.MatchLambda(_, _, clauses, _, _)
147183
| SynExpr.Match(_, _, clauses, _) ->
148184
let numCases = clauses |> List.sumBy countCasesInMatchClause // determine the number of cases in the match expression
149-
increaseComplexity (numCases + countBooleanOperators expression) // include the number of boolean operators in any when expressions, if applicable
185+
bindingStack.IncrComplexityOfCurrentScope (numCases + countBooleanOperators expression) // include the number of boolean operators in any when expressions, if applicable
150186
| _ -> ()
151187
| _ -> ()
152188

153189
// if the last node to be processed, pop everything off the stack
154190
if args.NodeIndex >= args.SyntaxArray.Length-1 then
155-
seq {
156-
match warningDetails with
157-
| Some x -> yield x
158-
| None -> ()
159-
while not BindingStack.IsEmpty do
160-
match popOffBindingStack() with
161-
| Some x -> yield x
162-
| None -> ()
163-
} |> Seq.toArray
191+
let fromStack = bindingStack
192+
|> Seq.sortBy (fun scope -> // sort by order of start position, for reporting
193+
let pos = scope.Binding.RangeOfBindingWithRhs.Start
194+
pos.Column, pos.Line)
195+
|> Seq.map (fun scope -> // transform into WarningDetails
196+
let errMsg = String.Format(Resources.GetString("RulesCyclomaticComplexityError"), scope.Complexity, config.MaxComplexity)
197+
{ Range = scope.Binding.RangeOfBindingWithRhs; Message = errMsg; SuggestedFix = None; TypeChecks = [] })
198+
|> Seq.toList
199+
let ret = match warningDetails with
200+
| Some x -> x::fromStack
201+
| None -> fromStack
202+
ret |> List.toArray
164203
else
165-
match warningDetails with
166-
| Some x -> [|x|]
167-
| None -> Array.empty
204+
Array.empty
168205

169206
/// Resets call stack after a call to runner.
170207
let cleanup () =
171-
BindingStack <- []
208+
match bindingStackOpt with
209+
| Some bs -> bs.Clear()
210+
| None _ -> ()
172211

173212
/// Generator function for a rule instance.
174213
let rule config =

tests/FSharpLint.Core.Tests/Rules/Conventions/CyclomaticComplexity.fs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ module FSharpLint.Core.Tests.Rules.Conventions.CyclomaticComplexity
22

33
open NUnit.Framework
44
open FSharpLint.Rules.CyclomaticComplexity
5-
open System
65

76
/// The max cyclomatic complexity as configured in these tests.
87
[<Literal>]
@@ -149,7 +148,7 @@ type TestConventionsCyclomaticComplexity() =
149148
[<TestCaseSource(nameof(TestConventionsCyclomaticComplexity.AtMaxComplexityTestCasesSource))>]
150149
member this.EnsureNoErrorWhenComplexityBelowThreshold(code) =
151150
this.Parse code
152-
Assert.IsFalse(this.ErrorsExist)
151+
Assert.IsTrue(this.NoErrorsExist)
153152

154153
/// Verifies that flags are raised on source code that has cyclomatic complexity > maxComplexity.
155154
[<TestCaseSource(nameof(TestConventionsCyclomaticComplexity.FailureCasesSource))>]
@@ -196,7 +195,7 @@ let f() =
196195
let g() =
197196
{makeMatchSnippetWithLogicalOperatorsInWhenClause (MaxComplexity / 2) |> indent 8}"""
198197
this.Parse code
199-
Assert.AreEqual(0, this.ErrorRanges.Length)
198+
Assert.IsTrue this.NoErrorsExist
200199

201200
/// Verifies that the cyclomatic complexity is calculated on functions independently by checking that a function that comes after a function with a cyclomatic complexity that is flagged as too high need not be flagged.
202201
[<Test>]
@@ -221,7 +220,7 @@ let f() =
221220
{makeMatchSnippet MaxComplexity |> indent 8}
222221
()"""
223222
this.Parse code
224-
Assert.AreEqual(0, this.ErrorRanges.Length)
223+
Assert.IsTrue this.NoErrorsExist
225224

226225

227226
/// Verifies that the cyclomatic complexity is calculated on nested functions independently by checking that a nested function that comes after another nested function with a cyclomatic complexity that is flagged as too high need not be flagged.
@@ -232,7 +231,22 @@ let f() =
232231
let g() =
233232
{(makeMatchSnippet (MaxComplexity+1)) |> indent 8}
234233
let h() =
235-
{makeMatchSnippet (MaxComplexity) |> indent 8}
234+
{makeMatchSnippet MaxComplexity |> indent 8}
236235
{makeMatchSnippet (MaxComplexity+1) |> indent 4}"""
237236
this.Parse code
238-
Assert.AreEqual(2, this.ErrorRanges.Length)
237+
Assert.AreEqual(2, this.ErrorRanges.Length)
238+
239+
/// Verifies that the multiple messages are not provided for a single function.
240+
[<Test>]
241+
member this.EnsureRedundantWarningsNotReported() =
242+
// generates a vapid match clause
243+
let genMatchClause i = $"""| "{i}" -> match str with
244+
| "A" -> ()
245+
| "B" -> ()"""
246+
// create a snippet of code with 10 match clauses
247+
let matchClauses = [ 1..10 ] |> List.map genMatchClause |> List.map (indent 4) |> String.concat NewLine
248+
let code = """Module Program
249+
let f (str: string) =
250+
match str with""" + NewLine + matchClauses
251+
this.Parse code
252+
Assert.AreEqual(1, this.ErrorRanges.Length)

0 commit comments

Comments
 (0)