Roslyn Tips & Tricks: Automatically adding using directives

David Acker
5 minutes read

Scenario

Imagine we're writing an implementation for the ASP0019 diagnostic added in .NET 8. One of the code fixes associated with this analyzer needs to convert calls to the Add method on IHeaderDictionary to calls to Append instead.
In terms of code, we'll need to convert this:
Response.Headers.Add("X-Total-Count", "5");
To this:
Response.Headers.Append("X-Total-Count", "5");
Simple, right? We can just switch the name from Add to Append and we'll be good to go! Well, here's the catch.
This Append method isn't defined on IHeaderDictionary. It's an extension method defined on HeaderDictionaryExtensions in the Microsoft.AspNetCore.Http namespace. So if we change the name of the method, but this using directive happens to be missing, we'll end up with a build error. That's no good. We'll need to account for this in our code fix.

Naive Approach: Using Fully Qualified Names

We could simply always reference the Append extension method using its fully qualified name, thus avoiding the need for the using directive.
Microsoft.AspNetCore.Http.HeaderDictionaryExtensions.Append(Response.Headers, "X-Total-Count", "5");
But, if a using directive for Microsoft.AspNetCore.Http already exists, we'd end up triggering another diagnostic IDE0002 in the process, as using the fully qualified name would be unnecessary.

Naive Approach: CompilationUnitSyntax

An alternative is to use the CompilationUnitSyntax from the document's root node. The Usings property provides access to the existing using directives allowing us to check if Microsoft.AspNetCore.Http is already included, so we can only add the using directive when necessary. However, this approach has some other downsides.

First, this approach is rather verbose. We'll need to go through all of the following steps to insert the using directive:
  1. Get the root syntax node from the document and verify that it's a CompilationUnitSyntax.
  2. Iterate over each of the existing directives in CompilationUnitSyntax.Usings and check to see if our desired using directive already exists.
  3. Instantiate a new UsingDirectiveSyntax using one of the UsingDirective methods on SyntaxFactory and add that to the list of existing using directives.
  4. Call WithUsings on our original CompilationUnitSyntax to create a new syntax with our updated using directives.
  5. Replace the root on the document with our updated one using WithSyntaxRoot.

This results in a lot of code that's not directly related to the problem we're trying to solve with our code fix.
private static async Task<Document> ReplaceWithAppend(...)
{
    // ...

    return document.WithSyntaxRoot(
        AddRequiredUsingDirectiveForAppend(root.ReplaceNode(diagnosticTarget, invocation)));
}

private static CompilationUnitSyntax AddRequiredUsingDirectiveForAppend(
    CompilationUnitSyntax compilationUnitSyntax)
{
    var usingDirectives = compilationUnitSyntax.Usings;

    var includesRequiredUsingDirective = false;
    var insertionIndex = 0;

    for (var i = 0; i < usingDirectives.Count; i++)
    {
        var namespaceName = usingDirectives[i].Name.ToString();

        var result = string.Compare("Microsoft.AspNetCore.Http", namespaceName, StringComparison.Ordinal);

        if (result == 0)
        {
            includesRequiredUsingDirective = true;
            break;
        }

        if (result < 0)
        {
            insertionIndex = i;
            break;
        }
    }

    if (includesRequiredUsingDirective)
    {
        return compilationUnitSyntax;
    }

    var requiredUsingDirective =
        SyntaxFactory.UsingDirective(
            SyntaxFactory.QualifiedName(
                SyntaxFactory.QualifiedName(
                    SyntaxFactory.IdentifierName("Microsoft"),
                    SyntaxFactory.IdentifierName("AspNetCore")),
                SyntaxFactory.IdentifierName("Http")));

    return compilationUnitSyntax.WithUsings(
        usingDirectives.Insert(insertionIndex, requiredUsingDirective));
}
On top of this, we'll need additional logic to account for any formatting options the user has set in their .editorconfig file. For example, what if the user has dotnet_sort_system_directives_first enabled? We'll need to check for these configuration values ourselves and adjust our logic accordingly:
var config = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Tree);
config.TryGetValue("dotnet_sort_system_directives_first", out var configValue);

if (string.IsNullOrEmpty(configValue))
{
    // Set default value
}

if (configValue.Equals(“true”, StringComparison.InvariantCultureIgnoreCase))
{
    // Add system directives first
}
else
{
    // ...
}
That's a lot of work just to add a single using directive. Is there a more elegant way to handle this problem?

Introducing the SyntaxAnnotation

A SyntaxAnnotation is used to attach metadata to pieces of syntax. These annotations are added to pieces of syntax using WithAdditionalAnnotations and can be queried by their Kind with GetAnnotations.
We'll need to get the INamedTypeSymbol for the class containing the Append extension method. First, we'll need the SemanticModel from the document. Then we'll get the INamedTypeSymbol by calling GetTypeByMetadataName, on the Compilation for the SemanticModel, and passing the fully qualified name of the class.
var model = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);

var headerDictionaryExtensionsSymbol = model.Compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Http.HeaderDictionaryExtensions");
Next, we'll create our SyntaxAnnotation. SyntaxAnnotation has a few overloads, but we're interested in the one allowing you to specify both the Kind of the annotation and provide an additional piece of Data, both of which are of type string. We'll want to set the Kind to “SymbolId”. To generate our Data for the SyntaxAnnotation, we'll use DocumentationCommentId.CreateReferenceId, which generates an string identifier used to reference our type symbol.
var referenceId = DocumentationCommentId.CreateReferenceId(headerDictionaryExtensionsSymbol);

var annotation = new SyntaxAnnotation("SymbolId", referenceId);
Finally, we'll attach the Simplifier.AddImportsAnnotation followed by the annotation we created, to the syntax node for the Append invocation. The AddImportsAnnotation tells Roslyn to look for “SymbolId” annotations on these nodes, which are then used to add using directives when the code action is performing its clean-up operations.
var invocationWithAnnotations = invocation.WithAdditionalAnnotations(Simplifier.AddImportsAnnotation, annotation);
This results in a much cleaner solution where Roslyn handles all of the heavy lifting for us.
private static async Task<Document> ReplaceWithAppend(Diagnostic diagnostic, Document document, InvocationExpressionSyntax invocation, CancellationToken cancellationToken)
{
    var model = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);

    var headerDictionaryExtensionsSymbol = model.Compilation.GetTypeByMetadataName("Microsoft.AspNetCore.Http.HeaderDictionaryExtensions");

    var annotation = new SyntaxAnnotation("SymbolId", DocumentationCommentId.CreateReferenceId(headerDictionaryExtensionsSymbol));

    var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
    var diagnosticTarget = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true);

    return document.WithSyntaxRoot(
        root.ReplaceNode(diagnosticTarget, invocation.WithAdditionalAnnotations(Simplifier.AddImportsAnnotation, annotation)));
}

Despite the benefits of this technique, I was surprised to find little to no documentation for this online. Shout out to Youssef Victor for suggesting this technique on my PR for ASP0019.
ASP0019: Use ‘IHeaderDictionary.Append’ code fix in action
Demonstration of the ASP0019 'Use IHeaderDictionary.Add' code fix being applied in VSCode.
If you're interested in the details of the ASP0019 analyzer or code fixes, you can read more about them here on the original issue and pull request: