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()));
}
}
}
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
Key Components:
-
FixableDiagnosticIds
: Links this CodeFix to the diagnostic emitted by our analyzer -
GetFixAllProvider()
returnsWellKnownFixAllProviders.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 importsSystem.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;
}
}
Rider users will see the CodeFix option alongside other suggestions in the lightbulb menu:
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();
}
}
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!
Top comments (0)