Article series
- Codesharing of the future
- Better experience through Roslyn Analyzers and Code Fixes ⬅
- Testing Source Generators, Roslyn Analyzers and Code Fixes
- Increasing Performance through Harnessing of the Memoization
- Adapt Code Generation Based on Project Dependencies
- Using 3rd-Party Libraries
- Using Additional Files
- High-Level API – ForAttributeWithMetadataName
- Reduction of resource consumption in IDE
- Configuration
- Logging
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'
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 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 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 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()
.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.