DEV Community

loading...
Cover image for Analysing a .NET Codebase with Roslyn

Analysing a .NET Codebase with Roslyn

Matt Hosking
Love creating systems and solving complex problems. Particularly interested in the dark art of making microservices work for you, not against you.
・5 min read

Supporting code for this article.

image

But Why Would I Want To Do This?

When you're dealing with a complex system, particularly one with a lot of interconnections (such as a microservice architecture), it can be very useful to extract and even visualise metadata about those connections. You may also want to assess the usage of a particular NuGet package, or where that critical helper class is actually used. You can certainly do all of this with Visual Studio itself, or even more effectively with ReSharper, but what if you then want to drill down further or extract the data about the usages to document or visualise some how? This is where using the Roslyn compiler can be very handy.

What Is Roslyn?

Roslyn is the open-source implementation of both the C# and Visual Basic compilers with an API surface for building code analysis tools.

To give you an idea of how it works and what it's capable of, have a look at the .NET Compiler Platform SDK model. Essentially, you can use .NET to analyse .NET code, representing all the metadata about types, usages and structure as objects. You can load a single file, a project or even a whole solution. This is all provided through a series of NuGet packages, some that leverage the MSBuild libraries.

Getting Started

The key NuGet package you'll need is Microsoft.CodeAnalysis. You can use Microsoft.CodeAnalysis.CSharp.Workspaces if you don't need Visual Basic support (does anyone really these days?) This will allow you to do everything with C# (.cs) files, but you'll likely also want to load C# projects (.csproj) and solution (.sln) files. For this you'll need Microsoft.CodeAnalysis.Workspaces.MSBuild. This has a dependency on Microsoft.Build, so also add a direct reference on this so it uses the latest (it defaults to 15.3 at time of writing, where 16.10 is latest). Don't worry about the restored using .NETFramework warning - it uses the binaries at runtime separately. Finally, you'll need Microsoft.Build.Locator, so your existing Visual Studio build tools can be used (or you'll promptly get a cryptic error at runtime).

Loading a Solution

The very first thing you'll need in your code looks like this:

if (!MSBuildLocator.IsRegistered)
{
  var instances = MSBuildLocator.QueryVisualStudioInstances().ToArray();
  MSBuildLocator.RegisterInstance(instances.OrderByDescending(x => x.Version).First());
}
Enter fullscreen mode Exit fullscreen mode

This will find all .NET Core SDKs (including .NET 5+) and use the latest version. Feel free to change the logic for which version to use to suit your needs (if you don't want the latest version installed). Next you need to create an MSBuildWorkspace to load your solution into:

var workspace = MSBuildWorkspace.Create();
workspace.SkipUnrecognizedProjects = true;
workspace.WorkspaceFailed += (sender, args) =>
{
  if (args.Diagnostic.Kind == WorkspaceDiagnosticKind.Failure)
  {
    Console.Error.WriteLine(args.Diagnostic.Message);
  }
};
Enter fullscreen mode Exit fullscreen mode

I use SkipUrecognizedProjects for those cases where you are using non C# projects mixed in (like anything kind of database or deployment project). The WorkspaceFailed handler is to log any issues that occur, as it will silently fail with no loaded documents or projects, depending on the error, without this. Now you can load the solution:

var solution = await workspace.OpenSolutionAsync(@"C:\Dev\MyDevThing\MySolution.sln", new ProgressBarProjectLoadStatus());

public class ProgressBarProjectLoadStatus : IProgress<ProjectLoadProgress>
{
  public void Report(ProjectLoadProgress value)
  {
    Console.Out.WriteLine($"{value.Operation} {value.FilePath}");
  }
}
Enter fullscreen mode Exit fullscreen mode

The OpenSolutionAsync requires the file name, but also a class that implements IProgress. I've provided a simple console output implementation here.

Working With a Project

It's pretty easy to get the project you want from the solution with something like:

var project = solution.Projects.SingleOrDefault(x => string.Equals(x.Name, projectName, StringComparison.OrdinalIgnoreCase))
Enter fullscreen mode Exit fullscreen mode

But what next? First you'll want to get a compilation context, as this is required for a lot of actions you may want to perform:

var compilation = await project.GetCompilationAsync();
Enter fullscreen mode Exit fullscreen mode

Once you have this, retrieving any type that would be visible in the context of this project is as simple as:

var typeSymbol = compilation.GetTypeByMetadataName(typeName);
Enter fullscreen mode Exit fullscreen mode

Find Usages of a Type

For this kind of scenario, the static class, SymbolFinder, is provided. Have a look at the documentation for it to see all the functionality it provides. Here is an example of finding usages of an interface

var typeSymbol = compilation.GetTypeByMetadataName("MyNamespace.IMyInteface");
await SymbolFinder.FindImplementationsAsync(typeSymbol, solution)
Enter fullscreen mode Exit fullscreen mode

Working With Files

This needs to be the full name of the type (although you won't usually need Assembly information) and for generics, you'll need to use the MyType`1 syntax (`1 indicates one type parameter, `2 is two, etc.). To load a file is easy as well:

var document = _project.Documents.Single(x => x.Name == fileName);
var syntaxRoot = await document.GetSyntaxRootAsync();
var semanticModel = await document.GetSemanticModelAsync();
Enter fullscreen mode Exit fullscreen mode

The first line speaks for itself, and the syntax root and semantic model are used for analyzing the document syntax and symbols, respectively. For example, to find a method in a file, then find all usages of that method in your solution:

var methodDeclaration = syntaxRoot.DescendantNodes().OfType<MethodDeclarationSyntax>().Single(x => x.Identifier.Text == methodName);
var semanticModel = compilation.GetSemanticModel(methodDeclaration .SyntaxTree);
var methodSymbol = semanticModel.GetDeclaredSymbol(methodDeclaration);
var callers = await SymbolFinder.FindCallersAsync(methodSymbol, solution);
Enter fullscreen mode Exit fullscreen mode

Or the same for a property in that file:

var propertyDeclaration = syntaxRoot.DescendantNodes().OfType<PropertyDeclarationSyntax>().Single(x => x.Identifier.Text == propertyName);
var semanticModel = compilation.GetSemanticModel(propertyDeclaration.SyntaxTree);
var methodSymbol = semanticModel.GetDeclaredSymbol(propertyDeclaration);
var callers = await SymbolFinder.FindCallersAsync(propertySymbol, solution);
Enter fullscreen mode Exit fullscreen mode

I find a great way to discover the class I'm after, is either to set a breakpoint and dig through the syntax root in the debugger, or explore the C# syntax namespace. From your found syntax instance, you then need the semantic model to find the symbol that relates to it. For this you can use GetDeclaredSymbol or GetSymbolInfo (see here for a good explanation on when to use which). This symbol is then something which has global meaning across your solution (as opposed to a syntax, which is just code in a file), and can therefore be used with the SymbolFinder class.

Where to Next?

There's plenty you can do with the calls you find with SymbolFinder and a lot more analysis you could perform. I highly recommend you take a look at the supporting source code to get some ideas (analyses itself).

Discussion (0)