Fixing Mistakes With Roslyn Code Fixes

Denis EkartDenis Ekart
7 min read

In this part of the series, we will focus on creating a code fix provider that will fix a diagnostic we reported with empty lines analyzer, a Roslyn analyzer we implemented in the previous article.


Storytime!

A week ago, we had a brilliant idea to implement an analyzer that would fail the entire build whenever multiple empty lines were encountered in our solution. Of course, someone made you push the code we implemented into production (hey, don't look at me). It just so happens that your company, CoolCorp, has ground to a halt because of that.

You get called into a web meeting titled "Fw: URGENT Who the hell broke our builds?!!". You enter the chat unbeknownst to the amount of trouble your Roslyn learning journey has caused the company. Your tech lead is bisecting the git history in front of senior management, trying to find the commit that broke everything. Ahh, here it is feat: disallow multiple subsequent empty lines (that will teach them).

The commit author, you, is now known to everyone. As you unmute your mic, you angle your web camera slightly to the right to reveal the famous quote from a guy that has something to do with faces, or books, or lizards.

"Move Fast and Break Things" by rossbelmont is licensed under CC BY-NC-SA 2.0. To view a copy of this license, visit https://creativecommons.org/licenses/by-nc-sa/2.0/?ref=openverse.

You speak up with confidence: I can fix it!

Enter code fixes

Roslyn allows you to write a code fix provider, that can enable the IDE to calculate a change to the solution that would fix the reported diagnostic. Let's break this down.

An analyzer will report a diagnostic with a unique id (e.g. DM0001). A code fix provider can register a code action capable of providing a fix for the issue from that diagnostic. This means that any time there is an error, warning, or even an informational squiggle in your IDE, chances are there is an accompanying code fix that can make that squiggle go away.

The code fix provider will take the existing solution, make the necessary syntax changes, and return a new, fixed solution.

Side note; you must follow several rules when implementing analyzers or code fixes for Roslyn. The diagnostic id needs to be unique across all analyzers and be in a specified format, must not be null, must have the correct category, ...

When developing a for Roslyn, there are several helpful analyzers and code fixes to aid in the development.

Writing a code fix provider

Starting with our existing analyzer project EmptyLinesAnalyzerAndCodeFix.csproj, let's create a new class and call it EmptyLinesCodeFix.

Before doing anything else, we need another NuGet package to help us manipulate common workspace items such as Documents. Head over to the CLI and run the following command.

dotnet add EmptyLinesAnalyzerAndCodeFix package Microsoft.CodeAnalysis.Workspaces.Common

With proper dependencies installed, the class can derive from Microsoft.CodeAnalysis.CodeFixes.CodeFixProvider abstract class. For the IDE to recognize that this is a code fix provider, we must also decorate it with an ExportCodeFixProviderAttribute. This is what a barebones code fix provider should look like.

using System;
using System.Collections.Immutable;
using System.Composition;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeFixes;

namespace EmptyLinesAnalyzerAndFix;

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(EmptyLinesCodeFix)), Shared]
public class EmptyLinesCodeFix : CodeFixProvider
{

    public override Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        // ...
    }

    public override ImmutableArray<string> FixableDiagnosticIds { get; }
}

If you are keen on exploring GitHub and you run across any code fix provider in the Roslyn repository, you might notice it is also decorated by a [Shared] attribute. The reason for that is code fix providers are MEF components. As per Roslyn guidelines, they should also be stateless. This means an IDE using a particular code fix provider can optimize its usage by creating a single shared instance of said code fix provider.

We first need is to provide our code fix provider with a list of diagnostics it can fix. Since we are only fixing our diagnostics and we also conveniently remembered to expose the DiagnosticId constant in the EmptyLinesAnalyzer, we can use it here.

public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(EmptyLinesAnalyzer.DiagnosticId);

This will ensure that any time the referenced diagnostic is encountered, this code fix provider will be able to fix it.

To be fair, our implementation can't fix anything yet. For this to happen, we also need to register a code action. A code action describes an intent to change one or more documents in a solution. Simply put, a code action allows the host (e.g. an IDE), to display a light bulb ๐Ÿ’ก, which enables you to apply a code fix.

For JetBrains Rider users, there is an annoying limitation feature in the IDE that allows you to apply a code fix only to a particular occurrence. Visual Studio, for example, will display several additional options for fixing your code and previewing the code fix.

Enable fixing multiple diagnostics

For the code fix provider to be able to fix multiple occurrences simultaneously, we also need to provide a "smart" way to achieve this. Luckily, Roslyn already offers a built-in solution for fixing batches of diagnostics in a single go (just be mindful of its limitations).

public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

Register the code fix

To enable the compiler to see and the IDE to display our code fix action, we need to (hint hint) implement the RegisterCodeFixesAsync method.

public override Task RegisterCodeFixesAsync(CodeFixContext context)
{
    const string action = "Remove redundant empty lines";

    var diagnostic = context.Diagnostics.First();
    context.RegisterCodeFix(
        CodeAction.Create(
            title: action,
            createChangedDocument: cancellationToken => /*...*/,
            equivalenceKey: action),
        diagnostic);

    return Task.CompletedTask;
}

The text specified in the title will be displayed when the user hovers over the light bulb. "Remove redundant empty lines" will also be used as an equivalenceKey. This means that by fixing multiple occurrences of this diagnostic (say, by executing the code action over the entire solution), all diagnostics with the same equivalence key will be fixed.

With all that ceremony out of the way, we are left with the final piece of the puzzle. To fix the code, we need to provide a factory that will be invoked by the compiler once the change is necessary. The createChangedDocument parameter takes a delegate. When invoked, this delegate will receive a cancellation token provided by the compiler and return a document or solution that was modified by our code fix provider.

The code fix provider needs to utilize the received cancellationToken properly. The compiler may, at any time, cancel the operation. For the IDE to remain responsive, any extensions to the compiler (e.g. our code fix provider) should react to cancellation requests immediately.

private static async Task<Document> RemoveEmptyLines(Document document,
    Diagnostic diagnostic,
    CancellationToken cancellationToken)
{
    // ...
}

Implement the code fix provider logic

We now have all the information available to fix our diagnostic. The document defines the syntax tree we will be modifying. It also represents the document where the diagnostic is located. The cancellationToken, as mentioned, should be passed to any time-consuming operations (or we could just cancellationToken.ThrowIfCancellationRequested(); when doing any CPU-bound work in our code).

And now, the fun part, let's figure out how to change the document to fix the diagnostic. ๐Ÿ‘จโ€๐Ÿ’ป

What? No. No peeking! Alright, I will include a link to the demo repository at the end of this article.

If you are adamant about trying to solve this yourself, here are a couple of tips to help you on the way.

  • Similarly to the previous article, we only need to deal with the syntax tree. This means that we can skip semantic analysis altogether and focus on a single document source code,

  • referencing the analyzer we implemented in the previous article, we were smart enough to include an additionalLocation which is a good place to start looking for the part of the syntax tree that needs to change,

  • remember, Roslyn syntax trees are immutable. There are several benefits to this. Although for us, this means that any time we need to change a node in that tree, it needs to be rebuilt from the ground up.

Let's see how our implemented solution works in the demo console app.

Great! We are done. Ship it. Now.

But wait, let's take a step back this time. Let's imagine not hearing Mark Zuckerberg uttering the words that decorate our office walls.

Let's be sane this time around and test our code instead of our boss's patience.

As promised, here is a link to the denisekart/exploring-roslyn repository, where you can find all the samples from this series. We will explore various ways of testing Roslyn analyzers and code fixes in the next installment.

Until next time, โœŒ

10
Subscribe to my newsletter

Read articles from Denis Ekart directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Denis Ekart
Denis Ekart

I am a highly motivated and innovative software engineer with almost a decade of professional experience in software design and development. My main interests include architecting performant cloud-native solutions and optimizing the development experience for myself and my colleagues. I have extensive knowledge and understanding of the .NET ecosystem, including ample expertise in cloud technologies such as Microsoft Azure. In my spare time, I enjoy improving myself and the community by contributing to free and open-source software.