Building refactoring tools (diagnostics and code fixes) with Roslyn

Β· 1518 words Β· 8 minutes to read

Some time ago I blogged about building next generation Visual Studio support tools with Roslyn. This was when Roslyn was still on its 2012 CTP. A lot has changed since then, with Roslyn going open source, and new iterations of the CTPs getting released.

Most of the APIs used in the original have changed, so I thought it would be a good idea to do a new post, and rebuilt the sample used in the old post from scratch, using the latest Roslyn CTP.

Pre-requisites πŸ”—

This sample was done using:

Note that according to the Roslyn team, the last public preview of Roslyn for Visual Studio 2013 is no longer supported anymore, so you really ought to be working with VS14 now.

Task πŸ”—

If you recall the old post, the fix we made then was quite simple, but illustrated the nature of building refactoring tools very nicely. If you have an ASP.NET Web API controller, which exposes some POST actions and their return is void, the framework would return a 204 (No Content) status code automatically. It is highly probable that with POST you are creating a resource, so 201 (Created) would typically be more appropriate.

As a result, with our tooling, we are going to suggest that you return a 201 status code instead, and provide an option for you to fix that with a click.

Creating a Roslyn Diagnostic πŸ”—

It’s extremely simple to get started with Roslyn code fixes. Once you have the Roslyn SDK installed, you will see a new category of projects pop up in your “New Project” wizard.

From there select, Diagnostic with Code Fix - which is effectively going to produce a VSIX, a Visual Studio plugin.

Screenshot 2014-10-13 11.47.12.

The new project already contains a sample implementation of a diagnostic and a code fix. These two pieces are essential for our refactoring tool.

Diagnostics are run by Visual Studio in the background as soon as anything changes in the file (you type a character). Diagnostic can determine if a fix is needed, and if so, run the code fix which will update the open document with appropriate changes.

Diagnostics can take different shapes and forms - all of which implement IDiagnosticAnalyzer. The default one, that the project template creates is an ISymbolAnalyzer, which allows you to inspect symbols. Others include ISemanticModelAnalyzer or ISyntaxNodeAnalyzer.

You are able to explicitly ask for specific types of symbols or syntax nodes only - such as method declarations, class declarations or maybe just local variable delcarations - depending on the category of diagnostic you are creating. This could provide you a useful context and a smaller subset of data to work with (rather than the entire document). This is controlled via the SyntaxKindsOfInterest property or SymbolKindsOfInterest (again, depending on the category of your diagnostic) on the diagnostic class.

Then you have to analyze either the syntax node or the symbol of your interest, and if your expected condition is met, add your diagnostic using the supplied delegate so that Visual Studio would display the yellow light bulb and the squiggly lines in the IDE in the place where the issue occurs.

Screenshot 2014-10-13 11.35.51

The user visible metadata (description, whether it’s a warning etc) is controlled by the DiagnosticDescriptor class, while the instance of a Diagnostic is created using a factory method Diagnostic.Create.

The whole diagnostic is shown below.

[DiagnosticAnalyzer]  
[ExportDiagnosticAnalyzer(DiagnosticId, LanguageNames.CSharp)]

public class WebApiPostActionAnalyzer : ISymbolAnalyzer  
{  
public const string DiagnosticId = "WebApiPostActionDiagnosticAndFix";  
internal static DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, "Consider returning 201", "Do not use void with Post actions", "Naming", DiagnosticSeverity.Warning, true);

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

public ImmutableArray<SymbolKind> SymbolKindsOfInterest  
{  
get  
{  
return ImmutableArray.Create(SymbolKind.Method);  
}  
}

public void AnalyzeSymbol(ISymbol symbol, Compilation compilation, Action<Diagnostic> addDiagnostic, AnalyzerOptions options, CancellationToken cancellationToken)  
{  
var methodSymbol = (IMethodSymbol)symbol;  
var httpControllerInterfaceSymbol = compilation.GetTypeByMetadataName("System.Web.Http.Controllers.IHttpController");

if (methodSymbol.ContainingType.AllInterfaces.Any(x => x.MetadataName == httpControllerInterfaceSymbol.MetadataName))  
{  
var postAttributeSymbol = compilation.GetTypeByMetadataName("System.Web.Http.HttpPostAttribute");

if (methodSymbol.ReturnsVoid && (methodSymbol.MetadataName.ToLowerInvariant().StartsWith("post") || methodSymbol.GetAttributes().Any(x => x.AttributeClass.MetadataName == postAttributeSymbol.MetadataName)))  
{  
var diagnostic = Diagnostic.Create(Rule, methodSymbol.Locations[0], methodSymbol.Name);  
addDiagnostic(diagnostic);  
}  
}  
}  
}  

So in our case, since we want POST Web API actions only, we are picking up only the methods which:

    • return void
    • are contained in a class that implements System.Web.Http.Controllers.IHttpController - so a Web API controller. Note that we are semantically checking if any of the parents implements it too. This is very cool, as we don’t care about the inheritance chain and how many base controllers the user has.
    • the method name starts with “post” or is decorated with System.Web.Http.HttpPostAttribute

For those types of methods, we are going to suggest a code fix. Note that our suggestion in this case is not critical, so our diagnostic is DiagnosticSeverity.Warning.

Creating a Roslyn Code Fix πŸ”—

Once we have determined, through the use of diagnostic, that we have a problem we are going to implement a code fix.

The code fix is correlated with a diagnostic through a diagnostic ID (btw, it doesn’t sound too structurally sound, since IDs are simply raw strings, but it is what it is). Note that one diagnostic can have multiple code fixes associated with it, as there might be various ways to fix a problem in the code. Each potential codefix is going to be presented by Visual Studio in the light-bulb menu, along with a small preview of what the change is going to look like after getting applied.

[img]

Implementing a code fix boils down to implementing ICodeFixProvider which is shown below:

public interface ICodeFixProvider  
{  
IEnumerable<string> GetFixableDiagnosticIds();  
Task<IEnumerable<CodeAction>> GetFixesAsync(Document document, TextSpan span, IEnumerable<Diagnostic> diagnostics, CancellationToken cancellationToken);  
}  

So we should (asynchronouusly) produce one or more _CodeAction_s which will be invoked by Visual Studio (also asynchronously) to fix the document. This is done using another factory method, CodeAction.Create, to which you pass a description of the fix to be used in the light-bulb menu and a delegate of the actual fix.

public static CodeAction Create(string description, Func<CancellationToken, Task<Document>> createChangedDocument);  

Other overloads of the factory allow you to pass a delegate modifying the entire solution (rather than document) or the actual corrected instance of the document/solution, should you choose to not use a delegate.

In our case, we need to do the following:

    • change return type from void to HttpResponseMessage
    • add return statement into the body of the method that looks like this: *return new HttpResponseMessage(HttpStatusCode.Created);

The only tricky bit is that if the method already has a return statement (it might or it might not - remember that we are dealing with a void return), we have to replace it.

When working with a code fix we don’t have to traverse the syntax tree of the whole document or look at the entire document’s semantic model any more - that is because the diagnostic has already done that for us, and given us the starting point in the form of diagnostic location. Using that, we can retrieve the method declaration we want to deal with.

[ExportCodeFixProvider(WebApiPostActionAnalyzer.DiagnosticId, LanguageNames.CSharp)]  
public class WebApiPostCodeFixProvider : ICodeFixProvider  
{  
public IEnumerable<string> GetFixableDiagnosticIds()  
{  
return new[] { WebApiPostActionAnalyzer.DiagnosticId };  
}

public async Task<IEnumerable<CodeAction>> GetFixesAsync(Document document, TextSpan span, IEnumerable<Diagnostic> diagnostics, CancellationToken cancellationToken)  
{  
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);  
var diagnosticSpan = diagnostics.First().Location.SourceSpan;  
var methodDeclaration = root.FindToken(diagnosticSpan.Start).Parent.AncestorsAndSelf().OfType<MethodDeclarationSyntax>().First();  
return new[] {  
CodeAction.Create("Change to HttpResponseMessage", c => {

var newReturnType = SyntaxFactory.ParseTypeName("HttpResponseMessage");  
var returnSyntax = SyntaxFactory.ReturnStatement(  
SyntaxFactory.ObjectCreationExpression(newReturnType)  
.WithNewKeyword(  
SyntaxFactory.Token(  
SyntaxFactory.TriviaList(),  
SyntaxKind.NewKeyword,  
SyntaxFactory.TriviaList(  
SyntaxFactory.Space)))  
.WithArgumentList(  
SyntaxFactory.ArgumentList(  
SyntaxFactory.SingletonSeparatedList(  
SyntaxFactory.Argument(  
SyntaxFactory.MemberAccessExpression(  
SyntaxKind.SimpleMemberAccessExpression,  
SyntaxFactory.IdentifierName(  
"HttpStatusCode"),  
SyntaxFactory.IdentifierName(  
"Created")))))))  
.WithReturnKeyword(  
SyntaxFactory.Token(  
SyntaxFactory.TriviaList(  
SyntaxFactory.Whitespace(  
" ")),  
SyntaxKind.ReturnKeyword,  
SyntaxFactory.TriviaList(  
SyntaxFactory.Space)))  
.WithSemicolonToken(  
SyntaxFactory.Token(  
SyntaxFactory.TriviaList(),  
SyntaxKind.SemicolonToken,  
SyntaxFactory.TriviaList()  
));

var oldReturnSyntax = methodDeclaration.DescendantNodes().OfType<ReturnStatementSyntax>().FirstOrDefault();  
MethodDeclarationSyntax newMethod;

if (oldReturnSyntax != null)  
{  
newMethod = methodDeclaration.ReplaceNode(oldReturnSyntax, returnSyntax);  
}  
else  
{  
newMethod = methodDeclaration.AddBodyStatements(returnSyntax);  
}

var replacedMethod = newMethod.ReplaceNode(newMethod.ReturnType, newReturnType);  
var replacedRoot = root.ReplaceNode(methodDeclaration, replacedMethod);  
var formattedRoot = Formatter.Format(replacedRoot, MSBuildWorkspace.Create());

return Task.FromResult(document.WithSyntaxRoot(formattedRoot));  
})  
};  
}  
}  

One thing that’s absolutely critical to know when working with Roslyn syntax trees is that Roslyn heavily leans on immutable types. Therefore, whenever you wish to update/modify any node in the tree, you have to explicitly call a relevant ReplaceNode method, as manipulating the node itself will not be enough.

In order to produce the return statement we lean on the SyntaxFactory class and its various factory methods. At the end, you can reformat the document using Formatter.Format method. The entire document is updated via WithSyntaxRoot method.

Before fix:

Screenshot 2014-10-13 11.36.06

After fix:

Screenshot 2014-10-13 11.36.17

What’s very useful, is that when building these types of diagnostic tools, there is a terrific debugging support. If you run the project from Visual Studio, it’s going to launch a new instance of Visual Studio, with your diagnostic and codefix installed in there as an extension. You can then open any project and test out/debug your extension with the debugger attached. This makes the whole process extremely easy to troubleshoot.

And that’s about it. Once compiled you can distribute and deploy your diagnostic+code fix using the produced VSIX.

The code from this post is available at Github.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP