In this Article

This article is the second part of a series about Roslyn Source Generators & co. In the previous article we built a Source Generator that extended 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, 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.

Article series

  1. Codesharing of the future
  2. Better experience through Roslyn Analyzers and Code Fixes
  3. Testing Source Generators, Roslyn Analyzers and Code Fixes

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 we want to extend is partial or not. If the class is not partial, 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

An Analyzer can have many 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.

Source code generation can be prevented at different stages. One option is to check for partial in DemoSourceGenerator.Execute, the other option is doing a similar check in the DemoSyntaxReceiver. I've decided on that latter option because this stage comes first.

The check for a keyword looks like:
classDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)). Please note two additional namespaces.
With the check, the method OnVisitSyntaxNode of the DemoSyntaxReceiver should look like this:

using System.Collections.Generic;
using System.Linq;                   // new
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp; // new
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace DemoSourceGenerator
{
   public class DemoSyntaxReceiver : ISyntaxReceiver
   {
      public List<ClassDeclarationSyntax> Candidates { get; } = new();

      public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
      {
         if (syntaxNode is not AttributeSyntax attribute)
            return;

         var name = ExtractName(attribute.Name);

         if (name != "EnumGeneration" && name != "EnumGenerationAttribute")
            return;

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

IsPartial is public because we will need this method 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(9, 47): [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 the developer probably did not intend. 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 possessinging 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.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
      }
   }
}

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

Unlike the syntax receiver, an Analyzer has all the type information required. 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.

Before checking for the existence of the keyword partial, we have to make sure that the current type is an enumeration. Afterward, we are iterating over all DeclaringSyntaxReferences and fetch the SyntaxNodes using the method GetSyntax. A SyntaxNode is the C# fragment we have been using in our DemoSyntaxReceiver in the previous article. If the SyntaxNode is a class declaration and not partial, 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 for 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 ||
             DemoSyntaxReceiver.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(7, 17): [DEMO001] The enumeration 'ProductCategory' must be partial

Roslyn Analyzer in action

Building an Analyzer does not take long once the whole project setup is there. 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 System.Linq;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeFixes;

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. Now, 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.

Roslyn 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 last article, we tested the code by either (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 circumstances, we need automated tests for all of the pieces we implemented. Read more about testing in the next part of this series.

If you don't want to miss out on more articles, webinars, videos, and more, subscribe to our monthly dev newsletter.

Related Articles

 | Pawel Gerr

Article series Code sharing of the future ⬅ Better experience through Roslyn Analyzers and Code Fixes Testing Source Generators, Roslyn Analyzers and Code Fixes Code Sharing Today The most common approach for sharing code/functionality is providing base/helper classes or…

Read article
 | Pawel Gerr

Article series Code sharing of the future Better experience through Roslyn Analyzers and Code Fixes Testing Source Generators, Roslyn Analyzers and Code Fixes More information about the smart enums, the source code, and the documentation of can be found on GitHub: Enum-like…

Read article
 | Pawel Gerr

By switching the to and setting to in the csproj-file we are now benefiting from the introduced with the latest version of C#. By enabling this new feature all type members, input and output parameters are considered to be not-null. If some members or parameters, like can…

Read article