DEV Community

Mikhail Nefedov
Mikhail Nefedov

Posted on

2

Roslyn CodeFix for updating code

Changes are inevitable when you are working with many repositories. As your applications and codebases evolve, methods are marked as obsolete and usage patterns fall out of favor. But how do you tackle a piece of code that’s been used hundreds of times and now needs to be replaced?

Manual refactorings of the occurrences can take a lot of time. That's where Roslyn code analyzers and CodeFixes may shine.

In this blog post, I’ll walk through how to build a simple Roslyn CodeFix that upgrades problematic code. For illustration, I will be using a real-world example: the IsNullOrEmpty() from Microsoft.IdentityModel.Tokens. This method was mistakenly published as public and later corrected to internal in the 8.0.0 release.

Note: The shown CodeFix may not work on every usage, but is perfect to illustrate some points.

Scanning for the Problem: Writing the Analyzer

Before we can fix anything, we need to find it. That’s where Roslyn analyzers come in. Roslyn analyzers let us inspect source code during compilation and raise diagnostics when a specific pattern is found. In our case, we’re looking for any usage of the now-internal CollectionUtilities.IsNullOrEmpty() method from Microsoft.IdentityModel.Tokens.

The analyzer below registers a syntax node action on every method invocation. If it spots a method named IsNullOrEmpty whose containing type matches Microsoft.IdentityModel.Tokens.CollectionUtilities, it reports a warning diagnostic.

Key parts are:

  • Diagnostic ID & Descriptor: This defines a unique ID, a readable message, and the severity level.

  • Initialize Method: Sets up the analyzer to run concurrently and skip generated code.

  • AnalyzeInvocation: The heart of the logic which inspects each method call and checks whether it matches the one we want to flag.

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace CodeFixDemo;

/// <summary>
/// Analyzer detecting usages of IsNullOrEmpty() from Microsoft.IdentityModel.Tokens
/// package. 
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class IsNullOrEmptyAnalyzer : DiagnosticAnalyzer
{
    // Preferred format of DiagnosticId is Your Prefix + Number, e.g. CA1234.
    public const string DiagnosticId = "DIAG0001";

    private const string Title = "Use custom IsNullOrEmpty instead of falsely exposed CollectionUtilities extension method";

    private const string MessageFormat = "Replace usage of Microsoft.IdentityModel.Tokens.CollectionUtilities.IsNullOrEmpty()";

    /// <summary>
    /// Category of the diagnostic -> in this case Usage.
    /// </summary>
    private const string Category = "Usage";

    private static readonly DiagnosticDescriptor Rule =
        new(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, true);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = 
        ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        // You must call this method to avoid analyzing generated code.
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);

        // Allow concurrent execution for better performance.
        context.EnableConcurrentExecution();
        context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
    }

    private void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
    {
        var invocation = context.Node as InvocationExpressionSyntax;
        var symbol = context.SemanticModel.GetSymbolInfo(invocation!).Symbol as IMethodSymbol;

        if (symbol?.ContainingType.ToDisplayString() == "Microsoft.IdentityModel.Tokens.CollectionUtilities"
            && symbol.Name == "IsNullOrEmpty")
        {
            context.ReportDiagnostic(Diagnostic.Create(Rule, invocation!.GetLocation()));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Building a Roslyn CodeFix

After detecting problematic code with our analyzer, the next step is to help developers fix it. This is where the CodeFixProvider comes into play. It listens for diagnostics we raised and offers users a one-click action to rewrite the code safely and consistently.

This IsNullOrEmptyCodeFixProvider listens for our analyzer's DIAG0001 diagnostic. When it detects a flagged usage of the IsNullOrEmpty() method from CollectionUtilities, it transforms that code into a more reliable inline check:

list is null || list.Any() is false
Enter fullscreen mode Exit fullscreen mode

Key Components:

  • FixableDiagnosticIds: Links this CodeFix to the diagnostic emitted by our analyzer
  • GetFixAllProvider() returns WellKnownFixAllProviders.BatchFixer to allow batched fixing
  • RegisterCodeFixesAsync: Registers the code fix with a title and links to the actual rewrite logic
  • ReplaceIsNullOrEmptyInvocationAsync: Constructs a new expression and imports System.Linq if needed
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;

namespace CodeFixDemo;

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(IsNullOrEmptyCodeFixProvider))]
public class IsNullOrEmptyCodeFixProvider : CodeFixProvider
{
    /// <summary>
    /// We use the static BatchFixer.
    /// <see href="https://github.com/dotnet/roslyn/blob/main/docs/analyzers/FixAllProvider.md#spectrum-of-fixall-providers"/>
    /// </summary>
    public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

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

    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken);
        if (root?.FindNode(context.Span) is not InvocationExpressionSyntax invocation)
        {
            return;
        }

        context.RegisterCodeFix(
            CodeAction.Create(
                title: "Replace with is null || .Any() is false",
                createChangedSolution: ct => ReplaceIsNullOrEmptyInvocationAsync(context.Document, invocation, ct),
                equivalenceKey: "ReplaceWithIsNullOrAny"), context.Diagnostics);
    }

    private async Task<Solution> ReplaceIsNullOrEmptyInvocationAsync(
        Document document,
        InvocationExpressionSyntax invocation,
        CancellationToken cancellationToken)
    {
        var editor = await DocumentEditor.CreateAsync(document, cancellationToken);

        var identifier = invocation.Expression.ChildNodes().OfType<IdentifierNameSyntax>().First();
        var newExpression = SyntaxFactory
            .ParseExpression($"{identifier} is null || {identifier}.Any() is false")
            .WithLeadingTrivia(invocation.GetLeadingTrivia())
            .WithTrailingTrivia(invocation.GetTrailingTrivia());
        editor.ReplaceNode(invocation, newExpression);

        var newDocument = editor.GetChangedDocument();

        if (await newDocument.GetSyntaxRootAsync(cancellationToken) is CompilationUnitSyntax compilationUnit &&
            compilationUnit.Usings.Any(u => u.Name.ToString() == "System.Linq") is false)
        {
            var linqUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName("System.Linq"));
            var newRoot = compilationUnit.AddUsings(linqUsing);
            newDocument = newDocument.WithSyntaxRoot(newRoot);
        }

        return newDocument.Project.Solution;
    }
}
Enter fullscreen mode Exit fullscreen mode

Rider users will see the CodeFix option alongside other suggestions in the lightbulb menu:

Roslyn action in Rider

Testing the CodeFix code

You can find all the code shown in this post in my GitHub repository. While building the CodeFix itself was rewarding, what surprised me was how useful and flexible the testing infrastructure turned out to be.

I started with the Roslyn Analyzer project template, which gives you an excellent out-of-the-box test setup. Especially if you don’t rely on external dependencies. But once you beginworking with external packages like Microsoft.IdentityModel.Tokens, adaptations to the tests need to be made to handle those references properly.

It is important to note the explicit usage of the CSharpCodeFixTest. This allows us to add additional assembly references (In this case: Microsoft.IdentityModel.Tokens).

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

namespace CodeFixDemo.Tests;

public class IsNullOrEmptyCodeFixProviderTests
{
    [Fact]
    public async Task Test()
    {
        // Arrange
        const string testCode = @"
using System.Collections.Generic;
using Microsoft.IdentityModel.Tokens;

public class Examples
{
    public void Example1() {
        IEnumerable<int> myEnumerable = new[] { 1, 2, 3, 4};
        if (myEnumerable.IsNullOrEmpty())
        {
            // do something
        }
    }
}
";

        const string expectedCode = @"
using System.Collections.Generic;
using Microsoft.IdentityModel.Tokens;
using System.Linq;

public class Examples
{
    public void Example1() {
        IEnumerable<int> myEnumerable = new[] { 1, 2, 3, 4};
        if (myEnumerable is null || myEnumerable.Any() is false)
        {
            // do something
        }
    }
}
";

        var codeFixTest = new CSharpCodeFixTest<
            IsNullOrEmptyAnalyzer,
            IsNullOrEmptyCodeFixProvider,
            XUnitVerifier>
        {
            TestCode = testCode,
            ReferenceAssemblies = new ReferenceAssemblies(
                    "net8.0", 
                    new PackageIdentity(
                        "Microsoft.NETCore.App.Ref", "8.0.0"), 
                    Path.Combine("ref", "net8.0"))
                .AddPackages(ImmutableArray.Create(new PackageIdentity("Microsoft.IdentityModel.Tokens", "7.0.0"))),
        };

        codeFixTest.ExpectedDiagnostics.Add(
            new DiagnosticResult(
                "DIAG0001", 
                Microsoft.CodeAnalysis.DiagnosticSeverity.Warning
            ).WithLocation(9, 13));

        codeFixTest.FixedCode = expectedCode;

        // Act & Assert
        await codeFixTest.RunAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

Wrapping it up

One thing I particularly appreciated while building this CodeFix: we didn’t need to import or rely on the external NuGet package in the analyzer code. That means our analyzer and the tests around remain untouched by future breaking changes. Since the example showcased here specifically targets Microsoft.IdentityModel.Tokens versions before 8.0.0, this isolation is a huge win for long-term maintainability.

This experience sparked a broader idea: using CodeFixes to systematically renew code across repositories. When legacy patterns get marked as Obsolete, manual updates can be a chore. Modernizing your solution becomes a breeze with a diagnostic and CodeFix in place

This command will apply your fix across all occurrences in the solution, saving time while keeping your codebase tidy:

dotnet format --diagnostics DIAG0001

You can explore the full source here: GitHub Repository: CodeFixDemo

If this post got you curious about CodeFixes and analyzer tooling, check out these fantastic articles:
Fixing mistakes with Roslyn CodeFixes
Testing Roslyn analyzers and CodeFixes
How to test a Roslyn analyzer

Thanks for reading!

Warp.dev image

Warp is the #1 coding agent.

Warp outperforms every other coding agent on the market, and gives you full control over which model you use. Get started now for free, or upgrade and unlock 2.5x AI credits on Warp's paid plans.

Download Warp

Top comments (0)

👋 Kindness is contagious

Explore this practical breakdown on DEV’s open platform, where developers from every background come together to push boundaries. No matter your experience, your viewpoint enriches the conversation.

Dropping a simple “thank you” or question in the comments goes a long way in supporting authors—your feedback helps ideas evolve.

At DEV, shared discovery drives progress and builds lasting bonds. If this post resonated, a quick nod of appreciation can make all the difference.

Okay