Roslyn analyzers: How to

 
 
  • Gérald Barré

This post is part of the series 'Roslyn Analyzers'. Be sure to check out the rest of the blog posts of the series!

This post contains a collection of samples for writing Roslyn analyzers. It's a living document that I will update over time.

#Find an IMethodSymbol from a compilation

C#
var type = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task")
var methods = type.GetMembers("GetAwaiter").OfType<IMethodSymbol>();

If you want a specific method when there are multiple overloads, you can use the DocumentationCommentId to get it directly. This is sometimes easier than filtering the methods manually. For example, to get the StringBuilder.Append(char) method, you can use the following code:

C#
DocumentationCommentId.GetFirstSymbolForDeclarationId("M:System.Text.StringBuilder.Append(System.Char)", context.Compilation) as IMethodSymbol

The documentation comment ID syntax is documented on GitHub. If you are unsure about the documentation comment id, you can use JetBrains Rider to find it. Place the cursor on the method name, and use the "Copy Reference" feature. Then, select "XML-doc ID":

#Check if a symbol is accessible from an assembly

You can check if a symbol is accessible from a specific assembly using the IsSymbolAccessibleWithin method. For example, the following code checks if the System.Threading.Tasks.Task type is accessible from the assembly represented by the compilation object:

C#
var assembly = compilation.Assembly;
var symbol = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task");
var isAccessible = compilation.IsSymbolAccessibleWithin(symbol, assembly);

#Access a configuration from the .editorconfig file

Analyzers can read configuration data from the .editorconfig file. This is useful to pass configuration data to the analyzer. For example, you could exclude a specific type from the analysis. The configuration data is read from the .editorconfig file using the AnalyzerConfigOptionsProvider class. The following code shows how to read the configuration data in an analyzer:

C#
public override void Initialize(AnalysisContext context)
{
    // context.Options is available in all RegisterXyzAction methods
    context.RegisterOperationAction(context =>
    {
        var syntaxTree = context.Operation.Syntax.SyntaxTree;

        // Get options for the current syntax tree
        // .editorconfig files are read from the directory of the syntax tree
        var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(syntaxTree);

        // Get the value of the my_analyzer.excludedTypes option
        if(options.TryGetValue("my_analyzer.configuration", out string configurationValue))
        {
            // todo
        }
    });
}

#Access additional files

Analyzers can read additional files that are passed to the compilation. This is useful to pass configuration data to the analyzer. For example, you can pass a file with a list of types that should be ignored by the analyzer. The file can be a text file, a JSON file, or any other file format. It's up to the analyzer to read the file and interpret its content. Additional files are often added from the .csproj file using the AdditionalFiles item group. For example, the following code adds a file named ConfigurationFile.txt to the compilation:

csproj (MSBuild project file)
<Project>
  <ItemGroup>
    <AdditionalFiles Include="ConfigurationFile.txt" />
  </ItemGroup>
</Project>

Here's an example of how to read the content of the file in an analyzer:

C#
public override void Initialize(AnalysisContext context)
{
    // context.Options is available in all RegisterXyzAction methods
    context.RegisterCompilationStartAction(context =>
    {
        var files = context.Options.AdditionalFiles;
        foreach(var file in files)
        {
            _ = file.Path;
            var sourceText = file.GetText(context.CancellationToken);
            if (sourceText is null)
                continue;

            foreach (var line in sourceText.Lines)
            {
                if (line.Span.IsEmpty)
                    continue;

                var lineText = line.ToString();
                // Do whatever you need to do with the line
            }
        }
    });
}

#Run an analyzer only if a specific symbol available

C#
public override void Initialize(AnalysisContext context)
{
    context.RegisterCompilationStartAction(context =>
    {
        var symbol = context.Compilation.GetTypeByMetadataName("System.Threading.Tasks.Task");
        if (symbol is null)
            return;

        context.RegisterOperationAction(...);
    });
}

#Check if a method is an operator

C#
public static bool IsOperator(this IMethodSymbol methodSymbol)
{
    return methodSymbol.MethodKind is MethodKind.UserDefinedOperator or MethodKind.Conversion;
}

#Get the namespace of a type

To get the namespace, you can recursively get the containing namespace of the type. Depending on the context, you can also use the SymbolDisplayFormat class to format the namespace. For example, to get the fully qualified namespace of a type, you can use the following code:

C#
ITypeSymbol type = ...;

var format = SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted);
type.ContainingNamespace?.ToDisplayString(format)

#Detect which version of .NET is used

Roslyn doesn't provide a direct way to detect which version of .NET is used in a project. However, you can use the Compilation object to check the references and determine the version of .NET. For example, you can check if the System.Object type is available in the compilation:

C#
var type = compilation.GetSpecialType(SpecialType.System_Object);
var version = type.ContainingAssembly.Identity.Version;
var isNet6OrLater = version.Major >= 6;

#Analyze the body of methods decorated with an attribute

C#
context.RegisterOperationBlockStartAction(context =>
{
    var attribute = context.Compilation.GetTypeByMetadataName("TestAttribute");
    if (attribute == null)
        return;

    var symbol = context.OwningSymbol;
    if (symbol.GetAttributes().Any(attr => SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attribute)))
    {
        // Register a scoped action to analyze the method body
        context.RegisterOperationAction(context => { }, OperationKind.Invocation);

    }
});

#Analyze documentation comments (XML comments)

C#
public override void Initialize(AnalysisContext context)
{
    context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration);
}

private void AnalyzeMethod(SyntaxNodeAnalysisContext context)
{
    var syntax = (MethodDeclarationSyntax)context.Node;
    if (!syntax.HasStructuredTrivia)
        return;

    foreach (var trivia in syntax.GetLeadingTrivia())
    {
        var structure = trivia.GetStructure();
        if (structure is null)
            continue;

        if (structure is not DocumentationCommentTriviaSyntax documentation)
            continue;

        foreach (var child in documentation.ChildNodes())
        {
            if (child is XmlElementSyntax elementSyntax)
            {
                if (elementSyntax.StartTag.Name.LocalName.Text == "summary")
                {
                    // Do something with the summary
                }
            }
        }
    }
}

#Get data flow

The AnalyzeDataFlow method can be used to get data flow information for a specific syntax node. The data flow result contains the symbols that are read, written, declared, and captured inside or outside the syntax node.

C#
var semanticModel = context.SemanticModel;
var dataFlow = semanticModel.AnalyzeDataFlow(syntax);
if (dataFlow.Succeeded)
{
    // dataFlow.ReadInside
    // dataFlow.WrittenInside
    // dataFlow.VariablesDeclared
    // dataFlow.CapturedInside
    // dataFlow.UsedLocalFunctions
    // etc.
}

#Get control flow

The control flow result contains the basic blocks, entry points, exit points, and control flow edges.

C#
var semanticModel = context.SemanticModel;
var controlFlow = semanticModel.AnalyzeControlFlow(statementSyntax);
if(controlFlow.Succeeded)
{
    // controlFlow.EntryPoints
    // controlFlow.ExitPoints
    // controlFlow.StartPointIsReachable
    // controlFlow.EndPointIsReachable
    // controlFlow.ReturnStatements
}

#Get C# language version

You can get the C# language version from the compilation options:

C#
SyntaxNode syntaxNode = ...;
if (syntaxNode.SyntaxTree.Options is CSharpParseOptions options)
{
    _ = options.LanguageVersion;
}

#Check if a symbol is accessible from outside the assembly

C#
static bool IsVisibleOutsideOfAssembly([NotNullWhen(true)] this ISymbol? symbol)
{
    if (symbol is null)
        return false;

    if (symbol.DeclaredAccessibility != Accessibility.Public &&
        symbol.DeclaredAccessibility != Accessibility.Protected &&
        symbol.DeclaredAccessibility != Accessibility.ProtectedOrInternal)
    {
        return false;
    }

    if (symbol.ContainingType is null)
        return true;

    return IsVisibleOutsideOfAssembly(symbol.ContainingType);
}

#Check if a member implements an interface member

C#
static bool IsInterfaceImplementation(this IMethodSymbol symbol)
{
    if (symbol.ExplicitInterfaceImplementations.Length > 0)
        return true;

    return IsInterfaceImplementation((ISymbol)symbol);
}

static bool IsInterfaceImplementation(this IPropertySymbol symbol)
{
    if (symbol.ExplicitInterfaceImplementations.Length > 0)
        return true;

    return IsInterfaceImplementation((ISymbol)symbol);
}

static bool IsInterfaceImplementation(this IEventSymbol symbol)
{
    if (symbol.ExplicitInterfaceImplementations.Length > 0)
        return true;

    return IsInterfaceImplementation((ISymbol)symbol);
}

static bool IsInterfaceImplementation(ISymbol symbol)
{
    var interfaces = symbol.ContainingType.AllInterfaces;
    foreach (var interfaceSymbol in interfaces)
    {
        foreach (var interfaceMember in interfaceSymbol.GetMembers())
        {
            var impl = symbol.ContainingType.FindImplementationForInterfaceMember(interfaceMember);
            var areEqual = SymbolEqualityComparer.Default.Equals(symbol, impl);
            if (areEqual)
            {
                return true;
            }
        }
    }

    return false;
}

#Remove trailing whitespace from a syntax node

C#
public static T WithoutTrailingSpacesTrivia<T>(this T syntaxNode) where T : SyntaxNode
{
    if (!syntaxNode.HasTrailingTrivia)
        return syntaxNode;

    var newTrivia = syntaxNode
        .GetTrailingTrivia()
        .Reverse()
        .SkipWhile(t => t.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.WhitespaceTrivia))
        .Reverse();
    return syntaxNode.WithTrailingTrivia(newTrivia);
}

#Support multiple versions of Roslyn

When writing an analyzer or a source generator, you might want to support multiple versions of Roslyn. This can be challenging because the APIs might change between versions. To support multiple versions of Roslyn, you can compile the analyzer or source generator against multiple versions of the Roslyn SDK. Then, you can create a NuGet package with a folder per Roslyn version. Roslyn will load the highest compatible version of the analyzer.

Documentation: https://github.com/dotnet/sdk/issues/20355

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?Buy Me A Coffee💖 Sponsor on GitHub