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 |