Roslyn logo

Static code analyzers schrijven met Roslyn

Rachid Techniek

Binnen Infi is het gebruik van static code analysis tegenwoordig niet meer weg te denken. SonarQube zie je bijvoorbeeld vaak terug in onze projecten. Zo bestaan er nog talloze manieren om je code te scannen op potentiële fouten en bugs. Toch zullen bestaande toepassingen nooit perfect aansluiten op ieder project en wensen. Ontwikkel je een .NET library, framework of SDK waarvan de gebruikers of je collega’s domeinspecifieke regels moeten volgen? Dan is het antwoord: Roslyn!

Terug in de tijd

Tien jaar geleden stond Anders Hejlsberg op het podium van Build 2014 in San Francisco. Met een druk de knop maakte hij Roslyn open-source en markeerde het begin van een nieuw tijdperk voor .NET en Microsoft. Niet veel later volgden nog meer open source releases die je vast niet ontgaan zijn: .NET Core, VSCode en Typescript.

Vóór de tijd van Roslyn (officieel genoemd The .NET Compiler Platform) waren de C# en Visual Basic compilers een closed-source black box. Je stopte er tekst in, en er kwam een binary uit. Tooling om met deze compilers te integreren was er simpelweg niet. Dit maakte de compiler-teams binnen Microsoft volledig verantwoordelijk voor Visual Studio’s zogeheten editing experience en de daarbij horende code analyse. De welbekende squiggly lines en code fixes worden door dat soort analyse mogelijk gemaakt.

Built-in C# Analyzer with Code Fix Error CS0246: The type or namespace name 'type/namespace' could not be found (are you missing a using directive or an assembly reference?)

Voor de gebruiker was dit ook verre van ideaal. De standaard analyse-regels waren niet heel uitgebreid, en alternatieven waren er nauwelijks.
Wie het zich kon veroorloven, gebruikte commerciële Visual Studio extensies zoals ReSharper.
Het nadeel hiervan was (behalve het prijskaartje) de performance. Vanwege het ontbreken van de nodige tooling moest er door dit soort extensies nogmaals parsing en compilatie werk worden gedaan – werk wat Visual Studio ook al doet.

Met de komst van Roslyn kon men eindelijk zelf code analyzers en source generators ontwikkelen. Er zijn drie soorten APIs:

  • Feature API voor refactoring en code-fixes
  • Workspace API voor IDE-acties zoals formatting, of het vinden van references
  • Compiler API voor Syntax Tree Analysis & compilaties

Genoeg gepraat, tijd voor code

We maken een analyzer die warnings toont bij het gebruik van Console.WriteLine en je vriendelijk verzoekt om ILogger<T> te gebruiken. Op dit moment is Visual Studio de enige IDE met project templates voor Roslyn. Gelukkig kan je het met een beetje werk ook zonder! Dit geeft ons de volgende structuur:

RoslynDemo/
|-- RoslynDemo.sln
|-- src
|   `-- RoslynDemo.Analyzers
|       |-- ConsoleLogAnalyzer.cs
|       `-- RoslynDemo.Analyzers.csproj
`-- test
    |-- RoslynDemo.Analyzers.IntegrationTest
    |   |-- Program.cs
    |   `-- RoslynDemo.Analyzers.IntegrationTest.csproj
    `-- RoslynDemo.Analyzers.Tests
        |-- ConsoleLogAnalyzerTests.cs
        `-- RoslynDemo.Analyzers.Tests.csproj

De anatomie van een analyzer

Om te beginnen, maken we een class die inherit van DiagnosticAnalyzer. Dit brengt gelijk al een paar spelregels met zich mee – we moeten de volgende members implementeren:

  • ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } Geeft aan welke diagnostics (error-, warning-, en info-meldingen) er worden ondersteund door deze analyzer.
  • void Initialize(AnalysisContext context); Hierin registreer je callbacks naar je eigen analyse methods, en geef je aan welke syntax nodes daarbij relevant zijn.
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace RoslynDemo.Analyzers;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ConsoleLogAnalyzer : DiagnosticAnalyzer
{
    public static readonly DiagnosticDescriptor Rule = new(
        id: "INFI0001",
        title: "Avoid direct usage of Console.WriteLine",
        messageFormat: "Consider using ILogger.Log() instead of Console.WriteLine()",
        category: "Usage",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Using Console.WriteLine for logging purposes can lead to inflexible logging implementations. " +
                     "Consider using ILogger or another logging framework to enable better logging practices.");

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        // We are only interested in invocations (method calls)
        context.RegisterSyntaxNodeAction(AnalyzeNode, SyntaxKind.InvocationExpression);
        
        // Do not analyze generated code
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        
        context.EnableConcurrentExecution();
    }

    private void AnalyzeNode(SyntaxNodeAnalysisContext context)
    {
        if (context.Node is InvocationExpressionSyntax invocation && IsConsoleLog(invocation))
        {
            var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation());
            context.ReportDiagnostic(diagnostic);
        }
    }

    private static bool IsConsoleLog(InvocationExpressionSyntax invocation)
    {
        return invocation.Expression is MemberAccessExpressionSyntax memberAccess 
               && memberAccess.Expression is IdentifierNameSyntax identifierName 
               && identifierName.Identifier.Text == "Console" 
               && memberAccess.Name.Identifier.Text == "WriteLine";
    }
}

In AnalyzeNode wordt elke methode-aanroep op basis van syntax geanalyseerd, en er wordt een warning gegenereerd als het een Console.WriteLine aanroep betreft.
Om van de analyzer gebruik te maken in je projecten, zul je helaas moeten publiceren naar NuGet. Er is een manier om te refereren naar lokale analyzers binnen dezelfde solution, maar vooralsnog lijkt dit enkel in Visual Studio goed te werken.

Unit Tests

Hoe goed is deze analyzer nou werkelijk? En hoe voorkomen we fouten en regressies? Voor een analyzer zo simpel als deze vraag je je misschien af wat er te testen valt. We verwachten een warning bij het gebruik van een verboden methode – en geen warning wanneer dat niet zo is. Betekent dit dat je met 2 test cases uit de voeten komt? Nee, want een taal als C# zit vol met features waarmee je regels omzeild kunnen worden. Wat als er een static import wordt gedaan van System.Console? En bij een alias en een static import?

Laten we alle denkbare scenario’s onder test brengen. Roslyn biedt een xunit package aan die hierbij van pas komt: Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;

namespace RoslynDemo.Analyzers.Tests;

public class ConsoleLogAnalyzerTests : CSharpAnalyzerTest<ConsoleLogAnalyzer,DefaultVerifier >
{
    [Fact]
    public async Task WhenNoMethodsAreUsed_Ignores()
    {
        TestCode = "public class Program { }";

        ExpectedDiagnostics.Clear();

        await RunAsync();
    }
    
    [Fact]
    public async Task WhenNoConsoleWriteLineIsUsed_Ignores()
    {
        TestCode = @"using System;

namespace RoslynDemo.Analyzers.IntegrationTest;

public class Program
{
    public static void Main(string[] args)
    {
        Console.ReadLine();
    }
}
";

        ExpectedDiagnostics.Clear();

        await RunAsync();
    }
    
    [Fact]
    public async Task WhenConsoleWriteLineIsUsedDirectly_ShowsWarning()
    {
        TestCode = @"using System;

namespace ConsoleApp;

public class Program
{
    public static void Main(string[] args)
    {
        Console.WriteLine();
    }
}
";
        ExpectedDiagnostics.Add(
            new DiagnosticResult(ConsoleLogAnalyzer.Rule.Id, DiagnosticSeverity.Warning)
                .WithMessage(ConsoleLogAnalyzer.Rule.MessageFormat.ToString())
                .WithSpan(9, 9, 9, 28));
        
        await RunAsync();
    }
    
    [Fact]
    public async Task WhenWriteLineIsUsedThroughStaticImport_ShowsWarning()
    {
        TestCode = @"using static System.Console;

namespace ConsoleApp;

public class Program
{
    public static void Main(string[] args)
    {
        WriteLine();
    }
}
";
        ExpectedDiagnostics.Add(
            new DiagnosticResult(ConsoleLogAnalyzer.Rule.Id, DiagnosticSeverity.Warning)
                .WithMessage(ConsoleLogAnalyzer.Rule.MessageFormat.ToString())
                .WithSpan(9, 9, 9, 20));
        
        await RunAsync();
    }
    
    [Fact]
    public async Task WhenWriteLineIsUsedThroughStaticImportAlias_ShowsWarning()
    {
        TestCode = @"using Foo = System.Console;

namespace RoslynDemo.Analyzers.IntegrationTest;

using static Foo;

public class Program
{
    
    public static void Main(string[] args)
    {
        WriteLine();
    }
}
";
        ExpectedDiagnostics.Add(
            new DiagnosticResult(ConsoleLogAnalyzer.Rule.Id, DiagnosticSeverity.Warning)
                .WithMessage(ConsoleLogAnalyzer.Rule.MessageFormat.ToString())
                .WithSpan(12, 9, 12, 20));
        
        await RunAsync();
    }
}

 

Dat is niet best: de directe aanroep is de enige vorm die gedetecteerd wordt door de analyzer. De static import en alias varianten worden niet herkend. Waarom?

Syntax vs Semantics

Het probleem is dat er niet genoeg informatie uit een AST (Abstract Syntax Tree) te halen valt om met zekerheid te zeggen welke code er precies wordt aangeroepen. Met de Syntax API kun je naar de structuur van een codebase kijken, maar het toont zijn limitaties als je op zoek bent naar details.

Hoewel een los codebestand of fragment van Visual Basic- of C#-code afzonderlijk syntactisch kan worden geanalyseerd, is het onmogelijk om vragen te stellen zoals “Wat is het type van variabele x?”. De exacte definitie van een type kan afhankelijk zijn van assembly references, imports en andere codebestanden – er moet dus eerst een compilatie plaatsvinden. Deze vragen worden beantwoord met behulp van de Semantic API, die kennis heeft van het gehele compilatie object.

Om gebruik te maken van het semantic model, maken we de volgende aanpassingen:

private void AnalyzeNode(SyntaxNodeAnalysisContext context)
{
    if (context.Node is not InvocationExpressionSyntax invocation)
        return;

    var symbol = context.SemanticModel.GetSymbolInfo(invocation).Symbol;

    if (IsConsoleLog(symbol))
    {
        var diagnostic = Diagnostic.Create(Rule, invocation.GetLocation());
        context.ReportDiagnostic(diagnostic);
    }
}

private static bool IsConsoleLog(ISymbol symbol)
{
    return symbol?.Kind == SymbolKind.Method
           && symbol.OriginalDefinition is IMethodSymbol methodSymbol
           && methodSymbol.Name == "WriteLine"
           && methodSymbol.ContainingType.ToString() == "System.Console";
}

 

Dankzij de tests kunnen we met gemak controleren of deze bug is opgelost.

Ship it

Zo simpel kan het schrijven van een C# analyzer zijn – het enige wat ons nog rest is het publiceren naar NuGet, en vervolgens kan het in ieder project worden gebruikt! Maar er zijn nog veel meer mogelijkheden. Zo kun je nog een CodeFixProvider toevoegen, waarmee gebruikers de warning eenvoudig kunnen oplossen met een enkele keystroke of muisklik. Verder kun je met behulp van IOperation taal-agnostische .NET analyzers en source generators maken (die dus ook zullen werken voor Visual Basic).

Powered by Roslyn

Hieronder nog een aantal handige analyzers en projecten die gebruik maken van Roslyn:

Vergelijkbare technische artikelen...

Een afspraak maken bij ons op kantoor of wil je even iemand spreken? Stuur ons een mail of bel met Jolanda.