Saturday, September 26, 2015

Creating a Roslyn code fix for Interpolated Strings in C#

In my previous post I showed a possible pitfall with the new C# string interpolation feature. I also showed how I created a Roslyn code analyzer to find these possible problems. As promised I will now follow up with the code fix provider that will help save you some typing when this analyzer fires a diagnostic message. Along the way I hope to show that creating code fixes is not as complicated as it might look.

So our goal is that if we get a diagnostic warning like



we would like our code fix to replace it with something like:

var today = DateTime.Today;
WriteLine(Invariant($"The date is {today}"));

Because I used the 'Analyser With Code Fix' template when creating the solution, I also got a CodeFixProvider into my solution to start with. This is the class where we will be fixing the code by adding the call to Invariant around the interpolated string.
The CodeFixProvider class starts with a constant string for the title and an overridden property where we can indicate which diagnostics we are willing to fix, in this case the same DiagnosticId we used in the analyzer.

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(StringInterpolationCodeFixProvider)), Shared]
public class StringInterpolationCodeFixProvider : CodeFixProvider
{
    private const string Title = "Wrap with Invariant(...)";

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

    // ...

}

The main entrypoint for the code fix is the virtual method RegisterCodeFixesAsync() which we need to override:

public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
{
    context.RegisterCodeFix(CodeAction.Create(Title, 
                                              c => ApplyFix(context, c), Title), 
                                              context.Diagnostics.First());
    return Task.FromResult(true);
}

In this method you can register one or more code fixes. Each code fix will show up under the light bulb icon in Visual Studio for the user to choose from. The code fix also contains a delegate that will actually perform the code fix when needed for the pre-view window or in the actual code when selected.

I decided to do all the actual work in the ApplyFix method which gets called form the delegate. This way the Visual Studio UI can show the fix is available as soon as possible which could make it a bit more responsive to the user. When Visual Studio then wants to render the preview window for the fix it will execute the code action which in turn calls ApplyFix()

private static async Task<Document> ApplyFix(CodeFixContext context, CancellationToken cancellationToken)
{
    var document = context.Document;
    var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);

    // We need to get the InterpolatedStringExpression that was found by the Analyser
    var diagnostic = context.Diagnostics.First();
    var position = diagnostic.Location.SourceSpan.Start;
    var interpolatedString = root.FindToken(position).Parent.AncestorsAndSelf()
        .OfType<InterpolatedStringExpressionSyntax>().First();

    var replacement = WrapWithCallToInvariant(interpolatedString);
    root = root.ReplaceNode(interpolatedString, replacement);
            
    return document.WithSyntaxRoot(root);
}

From the CodeFixContext we can get the Document and then the SyntaxRoot. The SyntaxRoot contains the parsed tree of the C# code we need to fix. We can also get the Diagnostic, which is the same Diagnostic we created in the Analyser. The Diagnostic does not contain the actual SyntaxNode it triggered for, just the location in the source. Therefore we need to find the token in the syntax tree based on the location of the diagnostic. From this token we can look upwards in the tree to find the containing InterpolatedStringExpression.

The code to wrap the interpolated string is extracted into WrapWithCallToInvariant() which we will look at shortly. Once we have our replacement we need to actually replace the interpolatedString with our replacement and finally return a new document with the new syntaxroot.

In order to create the correct replacement for the interpolated string we need to understand the syntax tree of what we are replacing and of what we want to replace it with. You can do this by using the Syntax Visualizer add in for visual studio. Using the syntax visualizer you can select a piece of code right inside the Visual Studio editor and inspect the syntax tree generated by the compiler.

Selecting the interpolated string $"The date is {dateTime}" produces the following Syntax tree

Figure 1


When wrapped in a call to Invariant like Invariant($"The date is {dateTime}"), it looks like this:

Figure 2


So it looks like we need to create a new InvocationExpression, with an IdentifierName "Invariant". The InvocationExpression also has an ArgumentList with a single Argument, this Argument then is our original InterpolatedStringExpression. It looks like this should not be to complicated.

One thing however to consider is that the static method Invariant() belongs to System.FormattableString. If the code has a using static System.FormattableString; in scope we can just use Invariant(). But if it does not, the resulting code will not compile. In order to be sure the code compiles we will generate the fully qualified name. We can then use simplification to reduce it to the shortest possible form.

The syntax tree when using the fully qualified name System.FormattableString.Invariant($"The date is {dateTime}) looks like this:

Figure 3
Now the InvocationExpression does not start with just an IndentifierName, but with a more complicated tree. The identifiers 'System', ' FormattableString' and 'Invariant' are linked together with two nested SimpleMemberAccesExpressions. We need to re-create this structure at run time in the code fix. Getting this exact structure is important because otherwise the simplifier will not understand the structure of the code and not be able to reduce the name correctly. (This was actually the part that took me 80% of the time to get right)

Creating new SyntaxNodes is done by static methods provided by the SyntaxFactory class, e.g. SyntaxFactory.InvocationExpression(). Because I needed a lot of calls to static methods of this class here I decided to add a using static to keep the code less verbose.

using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

So now we can implement WrapWithCallToInvariant(). It gets the InterpolatedStringExpression (Figure 1) as the input and needs to produce an InvocationExpression (Figure 3) as the output.

private static InvocationExpressionSyntax WrapWithCallToInvariant(ExpressionSyntax expressionToWrap) =>
    InvocationExpression(
        InvariantExpressionSyntax,
        ArgumentList(SeparatedList(new[] { Argument(expressionToWrap) }))
    );

private static readonly MemberAccessExpressionSyntax InvariantExpressionSyntax =
    MemberAccessExpression(
        SyntaxKind.SimpleMemberAccessExpression,
        MemberAccessExpression(
            SyntaxKind.SimpleMemberAccessExpression,
            IdentifierName("System"),
            IdentifierName("FormattableString")),
        IdentifierName("Invariant")
        )
    .WithAdditionalAnnotations(Simplifier.Annotation);

By looking carefully at the SyntaxTree from the visualizer it becomes fairly easy to create this structure in code. We start with an InvocationExpression which invokes the Invariant() Method and puts the original interpolated string into the argument list. Because the MemberAccessExpressionSyntax for System.FormattableString.Invariant will be the same every time we apply this fix and syntax trees are immutable, this object can be created once and cached in a static field. Calling .WithAdditionalAnnotations(Simplifier.Annotation) adds an annotation to this node which makes sure it will get simplified. That way System.FormattableString.Invariant will be reduced to FormattableString.Invariant or Invariant depending on which usings are in scope at the location we inject it.

And that's it, when running this analyzer and code fix in Visual Studio you will now get a warning whenever you use a string interpolation that might depend on the culture of the current thread. The code fix makes it easy to wrap it with a call to FormattableString.Invariant(), reducing the name to the shortest possible form at that specific location.

The code for this Analyzer / Code Fix is on GitHub, so you can check it out for your self. https://github.com/FrankBakkerNl/StringInterpolationAnalyzer


No comments:

Post a Comment