Incremental Roslyn Source Generators In .NET 6: Better Experience Through Roslyn Analyzers & Code Fixes – Part 2

This article is the second part of a series about Roslyn Source Generators & co. In the previous article we built an Incremental Source Generator that extends a custom class by creating and initializing the new property Items. This new property returns all items of a Smart Enum. Although the Source Generator is fully functional, I still don't consider the current state as 'finished'. During implementation of the Source Generator, we made several assumptions, which means the developers must know the internals to use it properly. If the author of the Source Generator is the only consumer of this tool, then we can leave it as it is. If not, then it would be a bold decision to expect others to have the same knowledge as the author. In this case, I highly recommend adding a Roslyn Analyzer to guide the developers in the right direction.

In diesem Artikel:

Incremental Roslyn Source Generators In .NET 6: Better Experience Through Roslyn Analyzers & Code Fixes – Part 2
Pawel Gerr ist Architekt und Consultant bei Thinktecture. Er hat sich auf .NET Core Backends spezialisiert und kennt Entity Framework von vorne bis hinten.

More background information about the Smart Enums and the source code can be found on GitHub:

Preparations

In this article, we want to implement a Roslyn Analyzer that checks whether the class is partial or not. If the class is not partial, then an error is emitted and the class name is highlighted.

As stated in the previous article, the Source Generators are packaged the same way as the Roslyn Analyzers, i.e. our projects are set up already, and we can start working on the actual Analyzer.

Definition of Errors and Warnings

A Roslyn Analyzer can have multiple different checks, represented by a DiagnosticDescriptor. A DiagnosticDescriptor defines the id, name, message, and severity, i.e., whether it is a warning or an error. It is a good idea to define all descriptors in one class, so let’s create the new class DemoDiagnosticsDescriptors in the project DemoSourceGenerator.

				
					using Microsoft.CodeAnalysis;

namespace DemoSourceGenerator;

public static class DemoDiagnosticsDescriptors
{
   public static readonly DiagnosticDescriptor EnumerationMustBePartial
      = new("DEMO001",                               // id
            "Enumeration must be partial",           // title
            "The enumeration '{0}' must be partial", // message
            "DemoAnalyzer",                          // category
            DiagnosticSeverity.Error,
            true);
}
				
			

The message may contain placeholders that we must replace when the error/warning is emitted.

Avoid Unnecessary and Misleading Errors

Before starting with the implementation of the Analyzer, let’s see what happens if the keyword partial is (temporarily) removed from ProductCategory.
Remove partial and try to build the project. The compiler error should be something like:

				
					ProductCategory.cs(7, 17): 
    [CS0260] Missing partial modifier on declaration of type 'ProductCategory';
             another partial declaration of this type exists
				
			

In this case, we are lucky, the compiler error is very precise, but this is the exception rather than the rule. Usually, if code is generated, even though basic preconditions are not met, the developers get dozens of (misleading) errors. In such cases, it would be better not to generate any code until the issues are fixed.

With Incremental Source Generators the code generation can be prevented at different stages. One option is to check for partial in the predicate CouldBeEnumerationAsync, the other option is doing a similar check during the transformation GetEnumTypeOrNull. I’ve decided on the first option because the predicate is called first and the check doesn’t require further type infos.

				
					[Generator]
public class DemoSourceGenerator : IIncrementalGenerator
{
   public void Initialize(IncrementalGeneratorInitializationContext context)
   {
      var enumTypes = context.SyntaxProvider
                          .CreateSyntaxProvider(CouldBeEnumerationAsync, GetEnumTypeOrNull);
      ...
				
			

The predicate CouldBeEnumerationAsync now checks for ClassDeclarationSyntax and partial as well.

				
					 private static bool CouldBeEnumerationAsync(
      SyntaxNode syntaxNode,
      CancellationToken cancellationToken)
   {
      if (syntaxNode is not AttributeSyntax attribute)
         return false;

      var name = ExtractName(attribute.Name);

      if (name is not ("EnumGeneration" or "EnumGenerationAttribute"))
         return false;

      // "attribute.Parent" is "AttributeListSyntax"
      // "attribute.Parent.Parent" is a C# fragment the attributes are applied to
      return attribute.Parent?.Parent is ClassDeclarationSyntax classDeclaration &&
             IsPartial(classDeclaration);
   }
   
   public static bool IsPartial(ClassDeclarationSyntax classDeclaration)
   {
      return classDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
   }
				
			

The new method IsPartial and existing method IsEnumeration are (made) public because we will need these methods later. An extension method would be the better alternative, but I leave it the way it is for didactical reasons.

Rebuild the solution to see that the previously mentioned error is gone, and a new one appears.

				
					   Program.cs(3, 38): [CS0117] 'ProductCategory' does not contain a definition for 'Items'
				
			

This error is correct. We cannot use the property Items because no code was generated.

Missing Analyzers may Lead to Bugs

This section wants to stress the importance of Analyzers.

Imagine the developer creates a non-readonly item, something like:

				
					   public static ProductCategory Fruits = new("Fruits"); // 'readonly' is missing
   public static readonly ProductCategory Dairy = new("Dairy");
				
			

In this case, the DemoSourceGenerator will skip the item Fruits, which probably wasn’t intended by the developer. It would be a good idea to search for static ProductCategory first and then check whether the member is (1) a field, (2) is public, and (3) is readonly. If some conditions are not met, then appropriate warning(s) should be emitted.

Roslyn Analyzer

An Analyzer is a C# class deriving from DiagnosticAnalyzer and possessing the DiagnosticAnalyzerAttribute. Create a new class DemoAnalyzer in the project DemoSourceGenerator with the following content:

				
					using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace DemoSourceGenerator;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class DemoAnalyzer : DiagnosticAnalyzer
{
   public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
      = ImmutableArray.Create(DemoDiagnosticsDescriptors.EnumerationMustBePartial);

   public override void Initialize(AnalysisContext context)
   {
      context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
      context.EnableConcurrentExecution();

      context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType);
   }

   private static void AnalyzeNamedType(SymbolAnalysisContext context)
   {
      // TODO
   }
}

				
			

First, we have to specify the SupportedDiagnostics, which is an array containing EnumerationMustBePartial. Second, we have to register for symbols we want to analyze; in our case, it is a NamedType. Unlike the early stages of a Source Generator, an Analyzer has all required type information from the start.

The setting GeneratedCodeAnalysisFlags.None means that we don’t want to analyse generated code.

Before checking for the existence of the keyword partial, we have to make sure that the current type is a Smart Enum. Afterwards, we iterate over all DeclaringSyntaxReferences and fetch the SyntaxNodes using the method GetSyntax. A SyntaxNode is the C# fragment we have been using in our DemoSourceGenerator in the previous article. If the SyntaxNode is a class declaration and not partial, then an error of type Diagnostic is emitted. The second parameter of Diagnostic.Create determines what part of the code is highlighted in IDE (Visual Studio, Rider, etc.) and what part of the code will be jumped to when double-clicking the error. The location is important for the Code Fixes as well; more on that later. We want to highlight the class name only; that’s why classDeclaration.Identifier. The third argument is a params object[] to provide the values to the error message like "The enumeration '{0}' must be partial".

				
					   private static void AnalyzeNamedType(SymbolAnalysisContext context)
   {
      if (!DemoSourceGenerator.IsEnumeration(context.Symbol))
         return;

      var type = (INamedTypeSymbol)context.Symbol;

      foreach (var declaringSyntaxReference in type.DeclaringSyntaxReferences)
      {
         if (declaringSyntaxReference.GetSyntax() 
             is not ClassDeclarationSyntax classDeclaration ||
             DemoSourceGenerator.IsPartial(classDeclaration))
            continue;

         var error = Diagnostic.Create(DemoDiagnosticsDescriptors.EnumerationMustBePartial,
                                       classDeclaration.Identifier.GetLocation(),
                                       type.Name);
         context.ReportDiagnostic(error);
      }
   }

				
			

After rebuilding the solution, we should see our error if the ProductCategory is still missing the keyword partial.

				
					   ProductCategory.cs(6, 14): [DEMO001] The enumeration 'ProductCategory' must be partial

				
			

Building an Analyzer does not take long once the whole project setup is done. So let’s look at the Roslyn Code Fixes next.

Roslyn Code Fix

Usually, a Code Fix is not essential, provided that the error or warning is self-explanatory. Still, in some cases, the developers probably would not mind getting some help.

For providing Code Fixes, we need a class deriving from CodeFixProvider with the ExportCodeFixProviderAttribute. We have now finished the preparations and can create a new class DemoCodeFixProvider in the project DemoSourceGenerator with the following content:

				
					using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace DemoSourceGenerator;

[ExportCodeFixProvider(LanguageNames.CSharp)]
public class DemoCodeFixProvider : CodeFixProvider
{
   public override ImmutableArray<string> FixableDiagnosticIds { get; }
      = ImmutableArray.Create(DemoDiagnosticsDescriptors.EnumerationMustBePartial.Id);

   public override FixAllProvider GetFixAllProvider()
   {
      return WellKnownFixAllProviders.BatchFixer;
   }

   public override Task RegisterCodeFixesAsync(CodeFixContext context)
   {
      // TODO

      return Task.CompletedTask;
   }
}
				
			

Similar to Analyzers, we have to define the FixableDiagnosticIds. The BatchFixer is the default FixAllProvider, which is used when multiple occurrences have to be fixed.

The method RegisterCodeFixesAsync is called every time a fixable diagnostic is emitted. In this method, we should check what diagnostic triggered the call. If it is EnumerationMustBePartial, create a CodeAction and register it.

				
					  public override Task RegisterCodeFixesAsync(CodeFixContext context)
   {
      foreach (var diagnostic in context.Diagnostics)
      {
         if (diagnostic.Id != DemoDiagnosticsDescriptors.EnumerationMustBePartial.Id)
            continue;

         var title = DemoDiagnosticsDescriptors.EnumerationMustBePartial.Title.ToString();
         var action = CodeAction.Create(title,
                                 token => AddPartialKeywordAsync(context, diagnostic, token),
                                 title);
         context.RegisterCodeFix(action, diagnostic);
      }

      return Task.CompletedTask;
   }
				
			

The callback AddPartialKeywordAsync is executed when the user wants to apply the Code Fix. To apply the fix, we need the corresponding class declaration first. The class declaration can be found when starting from makePartial.Location, which is the class name (see above classDeclaration.Identifier), and walking up the abstract syntax tree until we find the ClassDeclarationSyntax. Afterwards, the class declaration gets the PartialKeyword. Please note that the methods, like AddModifiers, don’t modify the existing objects but return new ones. The last step is to replace the old class declaration with the new one and to create a new document.

				
					 private static async Task<Document> AddPartialKeywordAsync(
      CodeFixContext context,
      Diagnostic makePartial,
      CancellationToken cancellationToken)
   {
      var root = await context.Document.GetSyntaxRootAsync(cancellationToken);

      if (root is null)
         return context.Document;

      var classDeclaration = FindClassDeclaration(makePartial, root);

      var partial = SyntaxFactory.Token(SyntaxKind.PartialKeyword);
      var newDeclaration = classDeclaration.AddModifiers(partial);
      var newRoot = root.ReplaceNode(classDeclaration, newDeclaration);
      var newDoc = context.Document.WithSyntaxRoot(newRoot);

      return newDoc;
   }

   private static ClassDeclarationSyntax FindClassDeclaration(
      Diagnostic makePartial,
      SyntaxNode root)
   {
      var diagnosticSpan = makePartial.Location.SourceSpan;

      return root.FindToken(diagnosticSpan.Start)
                 .Parent?.AncestorsAndSelf()
                 .OfType<ClassDeclarationSyntax>()
                 .First()!;
   }
				
			

Rebuild the solution to see the Code Fix in action.

Summary

Depending on the complexity of the Source Generator, the Analyzer could be as essential as the generator itself. I can imagine that the one-developer-team of a closed-source project has no need for any guidance provided by an Analyzer, but any other constellation probably does.

In this and the previous article, we tested the code either by (ab)using exceptions or looking at what happens when letting the code run. This approach works very well with relatively simple Source Generators and Analyzers, but in more complex scenarios, we need automated tests for all of the pieces we implemented. Read more about testing in the next part of this series.

Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Diese Artikel könnten Sie interessieren
.NET
Incremental Roslyn Source Generators in .NET 6: Adapt Code Generation Based on Project Dependencies – Part 5

Incremental Roslyn Source Generators in .NET 6: Adapt Code Generation Based on Project Dependencies – Part 5

The Roslyn Source Generator, implemented in the previous articles of the series, emits some C# code without looking at the dependencies of the current .NET (Core) project. In this article our DemoSourceGenerator should implement a JsonConverter, but only if the corresponding library (e.g. Newtonsoft.Json) is referenced by the project.
08.07.2022
Unterschiede
.NET
Blazor WebAssembly vs. Blazor Server – Welche Unterschiede gibt es und wann wähle ich was?

Blazor WebAssembly vs. Blazor Server – Welche Unterschiede gibt es und wann wähle ich was?

Das Blazor Framework von Microsoft gibt es inzwischen in drei "Geschmacksrichtungen". Die erste ist Blazor WebAssembly, die zweite Blazor Server, und zu guter Letzt gibt es noch Blazor Hybrid. In diesem Artikel wollen wir uns die zwei "echten", also Browser-basierten, Web-Anwendungs-Szenarien WebAssembly und Server anschauen.
04.07.2022
Three different textured walls
.NET
Dependency Injection Scopes in Blazor

Dependency Injection Scopes in Blazor

The dependency injection system is a big part of how modern ASP.NET Core works internally: It provides a flexible solution for developers to structure their projects, decouple their dependencies, and control the lifetimes of the components within an application. In Blazor - a new part of ASP.NET Core - however, the DI system feels a bit odd, and things seem to work a bit differently than expected. This article will explain why this is not only a feeling but indeed the case in the first place and how to handle the differences in order to not run into problems later on.
31.05.2022
.NET
Asynchrone Operationen: Blazor WebAssembly für Angular-Entwickler – Teil 5 [Screencast]

Asynchrone Operationen: Blazor WebAssembly für Angular-Entwickler – Teil 5 [Screencast]

Eine Webanwendung will natürlich auch mit Daten gefüttert werden. Doch diese müssen irgendwo her kommen. Nichts liegt näher als diese von einer Web API zu laden. Dieser Screencast zeigt, wie asynchrone Operationen in Blazor funktionieren und welche gravierenden Unterschiede es zu Angular gibt.
26.05.2022
.NET
Typings: Blazor WebAssembly für Angular-Entwickler – Teil 4 [Screencast]

Typings: Blazor WebAssembly für Angular-Entwickler – Teil 4 [Screencast]

C# und TypeScript entstammen der Feder der selben Person. Doch sind sie deshalb auch gleich? In diesem Teil der Screencast-Serie erfahren Sie, wie mit Typen in den beiden Programmiersprachen verfahren wird und welche Unterschiede es gibt.
19.05.2022
.NET
Bindings: Blazor WebAssembly für Angular-Entwickler – Teil 3 [Screencast]

Bindings: Blazor WebAssembly für Angular-Entwickler – Teil 3 [Screencast]

Wer Komponenten einsetzt, steht früher oder später vor der Fragestellung, wie man Daten an die Komponente übergibt oder auf Ereignisse einer Komponente reagiert. In diesem Screencast wird gezeigt wie Bindings bei Komponenten funktionieren, also wie eine Komponente Daten von außerhalb benutzen und Rückmeldung bei Aktionen geben kann.
12.05.2022