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 this article:

pg
Pawel Gerr is architect consultant at Thinktecture. He focuses on backends with .NET Core and knows Entity Framework inside out.

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.

Free
Newsletter

Current articles, screencasts and interviews by our experts

Don’t miss any content on Angular, .NET Core, Blazor, Azure, and Kubernetes and sign up for our free monthly dev newsletter.

EN Newsletter Anmeldung (#7)
Related Articles
.NET
KP-round
.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023
.NET
KP-round
Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023
.NET
KP-round
.NET 8 introduces a new Garbage Collector feature called DATAS for Server GC mode - let's make some benchmarks and check how it fits into the big picture.
09.10.2023