Rasmus Olsson

Why you might want a custom roslyn analyzer

Tags: dotnet,
October 23, 2023

Roslyn analyzer is a tool built on the .NET Compiler Platform which allows developers to perform static code analysis on their .NET projects. These analyzers inspect code for any potential issues, anything from style, code smells or whatever you define. If you use common IDE:s such as Visual Studio or Rider today, these IDEs leverage the Roslyn compiler platform to enhance your development experience. These IDE:s are by default using the standard configuration, which include a bunch of rules such as:

CA1822: Mark members as static to improve performance when the member does not access instance data.

In some cases, the default rules may not fully meet your needs. For instance, your organization might wish to augment these rules to cater to specific requirements. Lets say that your tech team or organization only should use DateTime.UtcNow and not DateTime.Now. This can be easily overlooked during a code review. However, if you share a common custom Roslyn analyzer this guideline will be very clear for everyone. The analyzer not only gives you a quick suggestion but can also detailing the rationale behind this rule and the story that led to its adoption.

In this blog post we will build a custom Roslyn Analyzer.

To create a Roslyn analyzer there are many different approaches and if you use Visual Studio, you already have pre-configured templates available. In this example I will not use the visual studio template but instead start from scratch.

We will start by adding a class library for the analyzer:

dotnet new classlib -n MyAnalyzer -o MyAnalyzer --framework netstandard2.0

We will then add the NuGet package that enables us to create an Analyzer:

dotnet add package Microsoft.CodeAnalysis.CSharp.Workspaces

A Roslyn analyzer can be divided into two different parts.

  1. Diagnostic: The first part involves analyzing the code to identify whether it adheres to the defined coding standards or contains specific issues.
  2. Code Fix: The second part comes into play once an issue has been identified. This is also where you are provided with a suggested solution.

I have separate this parts into two different classes.

Below is the Diagnostic class:

using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; namespace MyAnalyzer; [DiagnosticAnalyzer(LanguageNames.CSharp)] public class AsyncMethodNameAnalyzer : DiagnosticAnalyzer { public const string DiagnosticId = "AsyncMethodName"; private static readonly LocalizableString Title = "Method name should end with Async"; private static readonly LocalizableString MessageFormat = "Method '{0}' does not end with Async"; private static readonly LocalizableString Description = "Async methods should have names ending with Async."; private const string Category = "Naming"; private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description, helpLinkUri: "https://github.com/yourusername/yourrepository/blob/main/README.md#asyncmethodname"); public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule); public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.EnableConcurrentExecution(); context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration); } private static void AnalyzeMethod(SyntaxNodeAnalysisContext context) { var methodDeclaration = (MethodDeclarationSyntax)context.Node; if (methodDeclaration.Identifier.ValueText.EndsWith("Async") || !methodDeclaration.Modifiers.Any(SyntaxKind.AsyncKeyword)) { return; } var diagnostic = Diagnostic.Create(Rule, methodDeclaration.Identifier.GetLocation(), methodDeclaration.Identifier.ValueText); context.ReportDiagnostic(diagnostic); } }

DiagnosticAnalyzer - When inheriting from the DiagnosticAnalyzer base class, we give our tool the ability to automatically check the code. You can see it as a guard monitoring for intrusions. Whenever you build or write in the project, this diagnostic analysis will run.

DiagnosticDescriptor - The DiagnosticDescriptor is the definition of the analyzer rule. Here, you can specify the severity of the issue and other types of parameters. Another parameter is the helpLink, which can be very useful. Here, you could potentially point to a readme file explaining the background, the decision on why the analyzer exists, and how it works.

SupportedDiagnostics - The SupportedDiagnostics property serves as a bridge between our analyzer and the broader Roslyn analysis infrastructure. Here, you can specify a list of rules. In this example, we only have one.

Initialize - The Initialize method is the starting point for our analyzer. This is where we specify exactly what our analyzer should be paying attention to within the code. In this method, we register different analysis actions. In our case, we have a method declaration. We do this by using the context.RegisterSyntaxNodeAction method, specifying AnalyzeMethod as the action to perform, and targeting SyntaxKind.MethodDeclaration. The ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None) will ensure we ignore analyzing generated code, which can be problematic or time-consuming. EnableConcurrentExecution basically means that we can run the analyzer on multiple files at once in parallel.

AnalyzeMethod - The AnalyzeMethod is the logic used to check whether or not the code adheres to the specified rule.

The below is the Code Fix class:

using System.Collections.Immutable; using System.Composition; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Formatting; namespace MyAnalyzer; [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AsyncMethodNameCodeFixProvider)), Shared] public class AsyncMethodNameCodeFixProvider : CodeFixProvider { public sealed override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(AsyncMethodNameAnalyzer.DiagnosticId); public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) { var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); var diagnostic = context.Diagnostics.First(); var diagnosticSpan = diagnostic.Location.SourceSpan; var declaration = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType<MethodDeclarationSyntax>().FirstOrDefault(); context.RegisterCodeFix( CodeAction.Create( title: "Add Async suffix", createChangedSolution: c => AddAsyncSuffix(context.Document, declaration, c), equivalenceKey: "Add Async suffix"), diagnostic); } private async Task<Solution> AddAsyncSuffix(Document document, MethodDeclarationSyntax methodDecl, CancellationToken cancellationToken) { var identifierToken = methodDecl.Identifier; var newName = identifierToken.Text + "Async"; var newMethodDecl = methodDecl.WithIdentifier(SyntaxFactory.Identifier(newName)).WithAdditionalAnnotations(Formatter.Annotation); var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); var newRoot = root.ReplaceNode(methodDecl, newMethodDecl); var newDocument = document.WithSyntaxRoot(newRoot); return newDocument.Project.Solution; } }

ExportCodeFixProvider - Ensures that your code fix provider is discoverable.

FixableDiagnosticIds - Specifies which diagnostic IDs the code fix provider addresses, connecting the AsyncMethodNameAnalyzer.

GetFixAllProvider - Our override of GetFixAllProvider enables us to fix multiple instances of errors at once.

RegisterCodeFixesAsync - is the method where you bridge the gap between identifying code issues (via diagnostics) and resolving them (via code fixes).

AddAsyncSuffix - The AddAsyncSuffix method automatically appends "Async" to the names of asynchronous method declarations lacking this suffix, helping enforce naming conventions. It modifies the syntax tree of the given document and updates the project solution with the renamed method when the fix is applied.

Now that we have a bit better understanding of how the analyzer works, let's try it out.

To make the analyzer appear, I will create an async function without the async suffix method name:

private static async Task Delay() { await Task.Delay(1000); }

As we can see, it turns yellow, and when hovered over, it displays

picture for code fix example 1

And when pressing the code fix option, we can see 'Add Async Suffix':

picture for code fix example 2

And when pressed, the method will be renamed with an 'Async' suffix.

That's pretty much it. Happy coding!

please share