D
Dennis Tretyakov

Writing first Roslyn Analyzer and CodeFix Provider

Roslyn Analyzers — things that hook-up during .net build, as well as are used by IDEs and fancy editors, to provide tips for developers such as suggestions, warnings and even errors.

CodeFix Providers — when supported by IDE/editor provides an option to apply code fix programmatically.

It was promised to be very easy to create a custom one. I’ve tried, and long story short — it is easy. However, it took me quite a while to get started, primarily because of a lack of documentation and examples.

In this article there are the things that, I believe, would have saved my time, and hopefully they will save yours.

For details you can always refer to my example on github.

TOC

Pre-Requisites

Good news — you DON’T need Visual Studio to develop Roslyn Analyzers or CodeFix Providers or any other roslyn extensions. You DON’T NEED any Visual Studio Workloads and .NET Platform SDKs installed.

** The things you’ll really need: **

  1. IDE/Editor of your choice.
  2. Attach dotnet-tools to nuget sources. The easiest way to do so is to add NuGet.config to the solution source folder.
NuGet.config
<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <packageSources>
        <add key="dotnet-tools"             value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json"             protocolVersion="3" />    </packageSources>
</configuration>

The dotnet-tools nuget repository contains nuget packages required for testing: Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit, Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit there are also NUnit and MSTest versions

I have no idea why those packages are not there on nuget.org but that’s the way things are today. Also I’ve tried to go the “official” way — install so called “.NET Compiler Platform SDK” and create project using default template. Guess what — the project is referring the same packages, and cannot restore them. I believe the template is obsolete.

And yeah, even if you are not a fan of unit testing in this case this is the only way you can debug the code. At the same time it’s so straightforward that I believe you’ll love it.

So let’s jump into the code.

Coding, testing and debugging

First let’s define the goal

As an exercise we’ll make an analyzer that shows warning when custom exception class name doesn’t end with Exception and a codefix provider that suggests (and is able to) rename accordingly. Sounds simple, right? I even know some people who’d say that it has a right to exist in a real life.

At the end we should get something like behavior displayed below. ↓

Just to be clear, the screenshot is from Rider but the behavior should be the same across all IDEs/editors that support Roslyn (eg. Visual Studio 2019, Visual Studio Code and etc)

Creating solution

The folder/project structure in the example is as follows:

  • examples/ExampleAssembly # netstandard2.0 (could be any framework), we’ll use it only at the end to manually verify that things are working
  • src/PlaygroundAnalyzers # netstandard2.0 library, where we’ll put our Analyzer and CodeFix provider
  • test/PlaygroundAnalyzers.Tests # a test assembly (xunit, net5 in this example)

NuGet.config in the root directory

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <packageSources>
        <clear />
        <add key="public" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
        <add key="dotnet-tools" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json" protocolVersion="3" />
        <add key="local-folder" value="./packages/" />
    </packageSources>
</configuration>
  • In first two lines inside packages-sources — we remove all nuget sources for this solution and add public nuget. This might seem too much, but I wanted to make sure it works not only on my PC.

  • Next we add dotnet-tools the nuget source we discussed above.

  • Finally, we add local-folder source that will look into a ./packages/ folder, relative to solution root. We’ll need that to manually verify that our Analyzer and CodeFix work.

The last thing before coding is to install the packages we’ll need.

for src/PlaygroundAnalyzer

  • Microsoft.CodeAnalysis.CSharp.Workspaces (from public)

test/PlaygroundAnalyzers.Tests

  • Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit (from dotnet-tools)
  • Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit (from dotnet-tools)
  • assuming that you created xUnit test project with all other deps already in place. Just in case it should look like this.

Define DiagnosticDescriptor

In src/PlaygroundAnalyzer/Descriptors.cs

using Microsoft.CodeAnalysis;

namespace PlaygroundAnalyzers
{
    static class Descriptors
    {
        internal static readonly DiagnosticDescriptor PG0001ExceptionNameFormat = new(
            id: "PG0001",
            title: "Exception class name should end with Exception",
            messageFormat: "{0} class name should end with Exception",
            category: "Naming",
            defaultSeverity: DiagnosticSeverity.Warning,
            isEnabledByDefault: true);
    }
}

It seems quite obvious. However I’ll list the answers to the questions I had myself.

  • id — have no idea about maximal length and haven’t faced any strict format requirements. In the real life examples I’ve checked, everyone was following the same approach — short prefix + number. For example, c# compiler error codes are CS####. XUnit error codes are xUnit####.

  • title — the title of an issue in general

  • messageFormat — is use-case specific. The format arguments can be provided later.

  • category — there seem to be no information about it. There are few requests to Dotnet Foundation to provide more comments on that topic but other than that it seems you can organize the issues on categories as you please.

Well, you don’t have to do it exactly the same way but for a project with bunch of descriptors it’s comfy to have them all in one place (file). It also doesn’t make sense to make them public but since we’ll need to refer to this descriptor from test assembly as well, we’ll need to add an assembly attribute to give the test assembly access to internal types.

To do so add file src/PlaygroundAnalyzer/AssemblyInfo.cs with the following code:

using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("PlaygroundAnalyzers.Tests")]

Creating Analyzer

Typically, it’s impossible to imagine writing analyzer or codefix without lots of debugging and testing. Therefore, the normal workflow would be to create an empty analyzer (the one that does nothing or even crashes) then add at least one test for it in order to be able to debug and then write some analyzer code. For the simplicity of an example, I’ll just post all the analyzer code at once with comments.

In src/PlaygroundAnalyzer/ExceptionNameAnalyzer.cs


using System;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace PlaygroundAnalyzers
{
    // it seems obvious but just in case, it's necessary to apply an attribute for analyzer to be used
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class ExceptionNameAnalyzer : DiagnosticAnalyzer
    {
        // property is abstract and must be overriden,
        // when overriden should return immutable array of descriptors it operates with
        // in this case the one we described above
        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
            = ImmutableArray.Create(Descriptors.PG0001ExceptionNameFormat);

        public override void Initialize(AnalysisContext context)
        {
            // the next two stamements should be clear up to some level
            // not going to go deeper on that,
            // for now - it just should to be like that
            context.EnableConcurrentExecution();
            context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze |
                                                   GeneratedCodeAnalysisFlags.ReportDiagnostics);

            // this is where the coding starts,
            // in this case we register a handler (AnalyzeNamedType method defined below)
            // to be invoked analyzing NamedType (class, interface, delegate etc) symbols
            context.RegisterSymbolAction(action: AnalyzeNamedType, symbolKinds: SymbolKind.NamedType);
        }

        void AnalyzeNamedType(SymbolAnalysisContext ctx)
        {
            // there different kind of symbols but in this case we subscribed only for NamedType symbols
            var symbol = (INamedTypeSymbol) ctx.Symbol;
            if (symbol.TypeKind != TypeKind.Class) return;

            if (symbol.Name.EndsWith("Exception")) return;

            // as you might have noticed, in analyzer we don't work with System.Reflection model (like Types/PropertyInfos)
            // instead we work with Symbols model - a code tree structure based on text,
            // and the dope part - it exists even with code having compilation errors

            if (!IsException(
                symbol,
                ctx.Compilation.GetTypeByMetadataName(typeof(Exception).FullName))) return;

            // since we reached here -> we have an issue in code and we report it
            ctx.ReportDiagnostic(
                Diagnostic.Create(
                    // the descriptor
                    descriptor: Descriptors.PG0001ExceptionNameFormat,
                    // current symbol location in code (file, line and column for start/end),
                    // it will become more clear further, writing tests
                    location: symbol.Locations.First(),
                    // and those are the messageFormat format args,
                    // if you remember the messageFormat was: "{0} class name should end with Exception"
                    messageArgs: symbol.Name));
        }

        bool IsException(INamedTypeSymbol classSymbol, INamedTypeSymbol exceptionTypeSymbol)
        {
            if (classSymbol.Equals(exceptionTypeSymbol, SymbolEqualityComparer.Default)) return true;

            INamedTypeSymbol baseClass = classSymbol.BaseType;
            return baseClass != null && IsException(baseClass, exceptionTypeSymbol);
        }
    }
}

Analyzer test

The testing package we installed above contains a bunch of tools to run analyzers and codefix providers on a string, collect and compare results with expectations as well as test base classes used in this example. To be honest I find them quite clean and straightforward for general cases.

In test/PlaygroundAnalyzers.Tests/ExceptionNameAnalyzerFacts.cs

using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.CodeAnalysis.Testing.Verifiers;
using Xunit;

namespace PlaygroundAnalyzers.Tests
{
    public class ExceptionNameAnalyzerFacts : CSharpAnalyzerTest<ExceptionNameAnalyzer,XUnitVerifier>
    {
        [Fact]
        public async Task WhenCorrectName_Ignores()
        {
            TestCode = "public class CustomException : System.Exception { }";

            ExpectedDiagnostics.Clear();

            await RunAsync();
        }

        [Fact]
        public async Task WhenInconsistentName_ShowsWarning()
        {
            TestCode = "public class CustomError : System.Exception { }";

            ExpectedDiagnostics.Add(
                new DiagnosticResult(Descriptors.PG0001ExceptionNameFormat.Id, DiagnosticSeverity.Warning)
                    .WithMessage("CustomError class name should end with Exception")
                    .WithSpan(1, 14, 1, 25));  // Indexing here starts with one for both line and column,
                                               // the same way as you'd see it in IDE/editor.
                                               // Here we have 1st line, characters 14 to 25, what is CustomError text
                                               // this also is the area that becomes highlighted by IDE/editor
            await RunAsync();
        }
    }
}

Creating CodeFix Provider

Same as with analyzers, at first there would be an empty codefix (empty meaning RegisterCodeFixesAsync would do nothing or crash), then there come a test but for simplicity I’m just posting everything here with comments.

In In src/PlaygroundAnalyzer/ExceptionNameCodeFixFacts.cs

using System.Collections.Immutable;
using System.Composition;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Rename;

namespace PlaygroundAnalyzers
{
    // ExportCodeFixProvider attribute - is obvious
    // Shared - is required, not going there now... for now, just requried
    [ExportCodeFixProvider(LanguageNames.CSharp), Shared]
    public class ExceptionNameCodeFix : CodeFixProvider
    {
        // should be overriden and should return immutable array of ids that could be fixed by provider
        public override ImmutableArray<string> FixableDiagnosticIds { get; }
            = ImmutableArray.Create(Descriptors.PG0001ExceptionNameFormat.Id);

        // that's kinda simple - when there is a diagnostic result (produced by any analyzer)
        // with Id listed in FixableDiagnosticIds, the method is invoked
        public override async Task RegisterCodeFixesAsync(CodeFixContext context)
        {
            SyntaxNode root = await context.Document.GetSyntaxRootAsync(context.CancellationToken);
            SyntaxNode node = root?.FindNode(context.Span); // the span reported by analzyer

            if (node is not ClassDeclarationSyntax classDeclaration) return;

            SyntaxToken identifier = classDeclaration.Identifier;

            Document document = context.Document;
            Solution solution = document.Project.Solution;
            SemanticModel documentSemanticModel = await document.GetSemanticModelAsync(context.CancellationToken);
            ISymbol classModel = documentSemanticModel.GetDeclaredSymbol(classDeclaration, context.CancellationToken);
            string suggestedName = $"{identifier.Text}Exception";

            // Since we reached here we register a CodeAction which consists of
            // name - to be displayed to a user
            // createChangedSolution delegate - which gets invoked when user decides to take an action
            //                                  the delegate should return new solution model
            context.RegisterCodeFix(
                CodeAction.Create(
                    title: $"Rename to {suggestedName}",
                    // the solution is in immutable hierarchical model of everything (includes projects, documents and every syntax node)
                    // the delegate should return a new (modified) model based on the initial one,
                    // if you used react/redux - this should be very familair
                    // in most of cases you don't need to write a code for it, therefore there are utilities like Renamer (used below)
                    createChangedSolution: async cancellationToken => await Renamer.RenameSymbolAsync(
                        solution,
                        classModel,
                        suggestedName,
                        solution.Workspace.Options,
                        cancellationToken)),
                context.Diagnostics);

            await Task.CompletedTask;
        }
    }
}

CodeFix Provider test

I believe this is very straightforward and doesn’t require any comments. Though I’ll try to answer one potential question — yes, the test fully covers the analyzer test, and planning to have both Analyzer and CodeFix can use one test class from the very beginning. However, I don’t see any real value in it. The more granular tests are the better, even if there is some redundancy.

In test/PlaygroundAnalyzer.Tests/ExceptionNameAnalyzerFacts.cs

using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.CodeAnalysis.Testing.Verifiers;
using Xunit;

namespace PlaygroundAnalyzers.Tests
{
    public class ExceptionNameCodeFixFacts : CSharpCodeFixTest<ExceptionNameAnalyzer, ExceptionNameCodeFix, XUnitVerifier>
    {
        [Fact]
        public async Task WhenInconsistentName_AddsExceptionPostfix()
        {
            TestCode = "public class CustomError : System.Exception { }";

            ExpectedDiagnostics.Add(
                new DiagnosticResult(Descriptors.PG0001ExceptionNameFormat.Id, DiagnosticSeverity.Warning)
                    .WithMessage("CustomError class name should end with Exception")
                    .WithSpan(1, 14, 1, 25));

            FixedCode = "public class CustomErrorException : System.Exception { }";

            await RunAsync();
        }
    }
}

Distribution and Manual testing

There are two main ways how to distribute Roslyn Analyzers / CodeFix providers — NuGet package and VSIX. In this example we’ll use NuGet because this is a much more popular way. You might have not noticed but the major frameworks nowadays (Microsoft.AspnetCore.Mvc, Microsoft.EntityFramework, xUnit etc.) come with analyzers

The simplest way is to make a nuget package and put an assembly containing Analyzers/CodeFix providers to a nuget package under conventional path.

conventional assembly location in nuget package: analyzers\dotnet\[lang]\[assembly-name].dll

In this example that would be analyzers\dotnet\cs\PlaygroundAnalyzers.dll where cs obviously stands for C#. In .nuspec file you can specify exact location of .dll in package.

the src/PlaygroundAnalyzers/PlaygroundAnalyzers.nuspec

<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2011/10/nuspec.xsd">
  <metadata>
    <id>PlaygroundAnalyzers</id>
    <version>0.1.8-p128</version>
    <title>Playground Analyzers</title>
    <authors>Auhtor name</authors>
    <description>Playground analyzers</description>
  </metadata>
  <files>
    <file src="bin\$Configuration$\netstandard2.0\PlaygroundAnalyzers.dll" target="analyzers\dotnet\cs\" />  </files>
</package>

The next thing is to build and pack an assembly.

dotnet build -p:Configuration=Release .\src\PlaygroundAnalyzers\PlaygroundAnalyzers.csproj
nuget pack .\src\PlaygroundAnalyzers\PlaygroundAnalyzers.nuspec -p Configuration=Release -outputDirectory packages

In the example above the output directory for nuget pack is set to packages. This is where you should be able to locate the generated package. You can also open it with 7-Zip and double check that .dll was packed properly.

Since the packages folder was configured as one of the package source for the solution it can be installed in example project.

Now you should be able to install the package PlaygroundAnalyzers to an example assembly and test it, for example, by adding CustomError class that inherits Exception.

the examples/ExampleAssembly/CustomError.cs

using System;

namespace ExampleAssembly
{
    public class CustomError : Exception
    {

    }
}

The behaviour should be like ↓

Verifying changes manually: for each change there must be a new version (version number) of a nuget and the IDE/Editor must be restarted. At least this applies to Rider, Visual Studio or VS Code.

Support of old-style projects

The convention will work for all projects that support package references (e.g., any project nowadays). If by some reason you need to make an analyzer to work on projects that do not support packages references and cannot be upgraded there are powershell scripts for install/uninstall that update targets project file accordingly.

The scripts are generic and are included in visual studio template, or you can simply fetch them from the GitHub.

Obviously, this is just the top of the iceberg, but I hope this article will save your time making first steps.

© 2020 - 2024, Dennis Tretyakov