Tuesday, May 26, 2020

Computing cyclomatic complexity with F# 5.0 interactive and Mono.Cecil

Many moons ago, I posted a simple PowerShell script to use the FxCop SDK to introspect over a bunch of assemblies and compute a measure of the cyclomatic complexity of each method; in this case, by counting the number of IL branch instructions that do not branch to the next instruction, and which have a distinct target from any other branch.

That in turn was based upon the algorithm used in NDepend 1.x, which was sufficiently antique to not consider switch opcodes, a deficiency which can be worked around. And as an alternative, the algorithm used in Mono.Gendarme can be implemented in F# instead.

The main barrier to creating a simple replacement with Mono.Cecil has been the location of the assemblies with the NuGet package version in the file path. Now, however, with F# 5.0 interactive allowing reference-to-nuget statements, providing a pair of scripts that can do the job, without the awkward question "Where's Cecil?", becomes a simple task.

The classic version first

(*
.SYNOPSIS
This script estimates cyclomatic complexities over a folder full of assemblies.
.DESCRIPTION
It loads the assemblies, and then introspects over each method, estimating the
complexity as number of branch instructions which do not target the next instruction
and which have a unique target instruction.
.NOTES
File Name : ComputeComplexity.fsx
Requires : F# v5.0/.net 5.0
While .net 5.0 is in preview, use : dotnet fsi /langversion:preview ComputeComplexity.fsx
After that, use : dotnet fsi ComputeComplexity.fsx
.PARAMETER AssemblyPath
The path to the folder containging the assemblies to inspect
.PARAMETER ReportLevel
If given, print out methods matching or exceeding this complexity,
otherwise print out the whole analysis
*)
#r "nuget: Mono.Cecil"
#r "nuget: Mono.Options"
open System
open System.IO
open Mono.Cecil
open Mono.Cecil.Cil
open Mono.Cecil.Rocks
open Mono.Options
let mutable assemblyPath = String.Empty
let mutable ReportLevel = 0
let options =
[
("AssemblyPath=",
(fun x -> assemblyPath <- x |> Path.GetFullPath
if assemblyPath |> Directory.Exists |> not
then assemblyPath |> FileNotFoundException |> raise))
("ReportLevel=",
(fun x -> let (ok, n) = Int32.TryParse(x)
if ok
then ReportLevel <- n
else (x + " : Not a number") |> InvalidOperationException |> raise)
)
]
|> List.fold
(fun (o : OptionSet) (p, a) ->
o.Add(p, p.Trim('='), new System.Action<string>(a)))
(OptionSet())
try
fsi.CommandLineArgs
|> options.Parse
|> ignore
// IL branch opcodes
let branchOpCodes = [
OpCodes.Beq
OpCodes.Beq_S
OpCodes.Bge
OpCodes.Bge_S
OpCodes.Bge_Un
OpCodes.Bge_Un_S
OpCodes.Bgt
OpCodes.Bgt_S
OpCodes.Bgt_Un
OpCodes.Bgt_Un_S
OpCodes.Ble
OpCodes.Ble_S
OpCodes.Ble_Un
OpCodes.Ble_Un_S
OpCodes.Blt
OpCodes.Blt_S
OpCodes.Blt_Un
OpCodes.Blt_Un_S
OpCodes.Bne_Un
OpCodes.Bne_Un_S
OpCodes.Br
OpCodes.Br_S
OpCodes.Brtrue
OpCodes.Brtrue_S
OpCodes.Brfalse
OpCodes.Brfalse_S
]
// Compute method complexity (based on the algorithm of NDepend 1.3.2 by Patrick Smacchia)
let ComputeComplexity body =
let mutable offsets = Set.empty<int>
body |>
Seq.iter (fun (instruction:Instruction) ->
if instruction.OpCode.OperandType = OperandType.InlineSwitch
then instruction.Operand :?> Instruction array
|> Seq.filter (fun i -> (instruction.Next <> null) &&
(i.Offset <> instruction.Next.Offset))
|> Seq.iter (fun i -> offsets <- Set.add i.Offset offsets)
else if (branchOpCodes
|> Seq.exists (fun op -> instruction.OpCode = op))
then
let target = instruction.Operand :?> Instruction
if (instruction.Next <> null) &&
(target.Offset <> instruction.Next.Offset)
then offsets <- Set.add target.Offset offsets)
Set.count offsets
// load assemblies (no symbols needed) and explore methods
let assemblies = assemblyPath
|> Directory.GetFiles
|> Seq.filter (fun n -> n.EndsWith(".exe") || n.EndsWith(".dll"))
|> Seq.map (fun assembly ->
let a = AssemblyDefinition.ReadAssembly assembly
{|
Name = a.Name.FullName
Types = a.MainModule.GetAllTypes()
|> Seq.map (fun t -> {|
Name = t.FullName
Methods = t.Methods
|> Seq.filter (fun m -> m.CustomAttributes // skip compiler generated code
|> Seq.exists (fun a -> a.AttributeType.FullName
= "System.Runtime.CompilerServices.CompilerGeneratedAttribute")
|> not)
|> Seq.filter (fun m -> m.HasBody)
|> Seq.map (fun m -> {|
Name = m.Name
Complexity = ComputeComplexity m.Body.Instructions
|})
|> Seq.toList
|}
)
|> Seq.toList
|}
)
|> Seq.toList
if ReportLevel <> 0 then
let mutable aname = String.Empty
let mutable cname = String.Empty
assemblies
|> List.iter (fun a -> aname <- a.Name
a.Types
|> List.iter (fun t -> cname <- t.Name
t.Methods
|> List.iter (fun m -> if m.Complexity >= ReportLevel
then
if aname |> String.IsNullOrEmpty |> not
then printfn "%s" aname
aname <- String.Empty
if cname |> String.IsNullOrEmpty |> not
then printfn "\t%s" cname
cname <- String.Empty
printfn "\t\t%s => %d" m.Name m.Complexity
)
)
)
else
assemblies
|> List.iter (fun a -> printfn "%s" a.Name
a.Types
|> List.iter (fun t -> printfn "\t%s" t.Name
t.Methods
|> List.iter (fun m -> printfn "\t\t%s => %d" m.Name m.Complexity)))
with
| x -> printfn "%s" <| x.ToString() //x.Message

and another using the algorithm from Mono.Gendarme

(*
.SYNOPSIS
This script estimates cyclomatic complexities over a folder full of assemblies.
.DESCRIPTION
It loads the assemblies, and then introspects over each method, estimating the
complexity as number of branch instructions which do not target the next instruction
and which have a unique target instruction.
.NOTES
File Name : ComputeComplexity2.fsx
Requires : F# v5.0/.net 5.0
While .net 5.0 is in preview, use : dotnet fsi /langversion:preview ComputeComplexity2.fsx
After that, use : dotnet fsi ComputeComplexity2.fsx
.PARAMETER AssemblyPath
The path to the folder containging the assemblies to inspect
.PARAMETER ReportLevel
If given, print out methods matching or exceeding this complexity,
otherwise return the whole analysis to the pipeline
*)
#r "nuget: Mono.Cecil"
#r "nuget: Mono.Options"
open System
open System.IO
open Mono.Cecil
open Mono.Cecil.Cil
open Mono.Cecil.Rocks
open Mono.Options
let mutable assemblyPath = String.Empty
let mutable ReportLevel = 0
let options =
[
("AssemblyPath=",
(fun x -> assemblyPath <- x |> Path.GetFullPath
if assemblyPath |> Directory.Exists |> not
then assemblyPath |> FileNotFoundException |> raise))
("ReportLevel=",
(fun x -> let (ok, n) = Int32.TryParse(x)
if ok
then ReportLevel <- n
else (x + " : Not a number") |> InvalidOperationException |> raise)
)
]
|> List.fold
(fun (o : OptionSet) (p, a) ->
o.Add(p, p.Trim('='), new System.Action<string>(a)))
(OptionSet())
try
fsi.CommandLineArgs
|> options.Parse
|> ignore
// Gendarme's algorithm to compute Cyclomatic Complexity values.
let mask = [ 0xFFFF6C3FCUL; 0x1B0300000000FFE0UL; 0x400100FFF800UL; 0xDE0UL ]
let findFirstUnconditionalBranchTarget(ins : Cil.Instruction) =
Seq.unfold
(fun (state : Cil.Instruction) ->
if isNull state then None else Some(state, state.Next)) ins
|> Seq.tryFind (fun i -> i.OpCode.FlowControl = FlowControl.Branch)
|> Option.map (fun i -> i.Operand :?> Cil.Instruction)
let accumulateSwitchTargets (ins : Cil.Instruction)
(targets : System.Collections.Generic.HashSet<Cil.Instruction>) =
let cases = ins.Operand :?> Cil.Instruction []
cases
|> Seq.iter (fun target ->
if target <> ins.Next then
target
|> targets.Add
|> ignore)
// add 'default' branch (if one exists)
let next = ins.Next
if next.OpCode.FlowControl = FlowControl.Branch then
let operand = next.Operand :?> Cil.Instruction
match cases
|> Seq.head
|> findFirstUnconditionalBranchTarget with
| Some unc when unc = operand -> ()
| _ ->
operand
|> targets.Add
|> ignore
let ``detect ternary pattern`` (code : Code option) =
// look-up into a bit-string to get
// +1 for any Load instruction, basically
let index = int (if Option.isSome code then Option.get code else Code.Nop)
((mask
|> Seq.skip (index >>> 6)
|> Seq.head
&&& (1UL <<< (index &&& 63))
<> 0UL) :> IConvertible).ToInt32(System.Globalization.CultureInfo.InvariantCulture)
let switchCyclomaticComplexity(instructions : Cil.Instruction seq) =
let targets = System.Collections.Generic.HashSet<Cil.Instruction>()
let complexity (c:int) (i:Instruction) =
match i.OpCode.FlowControl with
| FlowControl.Branch ->
c + ((if i.Previous |> isNull then None else Some i.Previous)
|> Option.map (fun (previous : Instruction) ->
do if previous.OpCode.FlowControl = FlowControl.Cond_Branch then
match previous.Operand with
| :? Cil.Instruction as branch ->
if targets.Contains branch then
i
|> targets.Add
|> ignore
| _ -> ()
previous.OpCode.Code)
|> ``detect ternary pattern``)
| FlowControl.Cond_Branch ->
if i.OpCode = OpCodes.Switch then
accumulateSwitchTargets i targets
c
else
let branch = i.Operand :?> Cil.Instruction
c + ((if branch.Previous |> isNull then None else Some branch.Previous)
|> Option.filter (fun (previous : Instruction) ->
previous.Previous.OpCode.Code <> OpCodes.Switch.Code && branch
|> targets.Contains
|> not)
|> (fun x -> if Option.isSome x then 1 else 0))
| _ -> c
let fast =
instructions
|> Seq.fold complexity 1
fast + targets.Count
let cyclomaticComplexity(m : MethodDefinition) =
if m.HasBody then
let instructions = m.Body.Instructions |> Seq.cast<Cil.Instruction>
match instructions |> Seq.tryFind (fun i -> i.OpCode = OpCodes.Switch) with
| None ->
instructions
|> Seq.fold (fun c i ->
match i.OpCode.FlowControl with
| FlowControl.Cond_Branch -> c + 1
| FlowControl.Branch ->
c + ((if i.Previous |> isNull then None else Some i.Previous)
|> Option.map
(fun (previous : Instruction) -> previous.OpCode.Code)
|> ``detect ternary pattern``)
| _ -> c) 1
| _ -> switchCyclomaticComplexity instructions
else
1
// load assemblies (no symbols needed) and explore methods
let assemblies = assemblyPath
|> Directory.GetFiles
|> Seq.filter (fun n -> n.EndsWith(".exe") || n.EndsWith(".dll"))
|> Seq.map (fun assembly ->
let a = AssemblyDefinition.ReadAssembly assembly
{|
Name = a.Name.FullName
Types = a.MainModule.GetAllTypes()
|> Seq.map (fun t -> {|
Name = t.FullName
Methods = t.Methods
|> Seq.filter (fun m -> m.CustomAttributes // skip compiler generated code
|> Seq.exists (fun a -> a.AttributeType.FullName
= "System.Runtime.CompilerServices.CompilerGeneratedAttribute")
|> not)
|> Seq.filter (fun m -> m.HasBody)
|> Seq.map (fun m -> {|
Name = m.Name
Complexity = cyclomaticComplexity m
|})
|> Seq.toList
|}
)
|> Seq.toList
|}
)
|> Seq.toList
if ReportLevel <> 0 then
let mutable aname = String.Empty
let mutable cname = String.Empty
assemblies
|> List.iter (fun a -> aname <- a.Name
a.Types
|> List.iter (fun t -> cname <- t.Name
t.Methods
|> List.iter (fun m -> if m.Complexity >= ReportLevel
then
if aname |> String.IsNullOrEmpty |> not
then printfn "%s" aname
aname <- String.Empty
if cname |> String.IsNullOrEmpty |> not
then printfn "\t%s" cname
cname <- String.Empty
printfn "\t\t%s => %d" m.Name m.Complexity
)
)
)
else
assemblies
|> List.iter (fun a -> printfn "%s" a.Name
a.Types
|> List.iter (fun t -> printfn "\t%s" t.Name
t.Methods
|> List.iter (fun m -> printfn "\t\t%s => %d" m.Name m.Complexity)))
with
| x -> printfn "%s" <| x.ToString() //x.Message