Intro
We have the following enumeration class.
public partial class Hamburger : IEnumeration<Hamburger>
{
public static readonly Hamburger Cheeseburger = new (1, "Cheeseburger");
public static readonly Hamburger BigMac = new(2, "Big Mac");
public static readonly Hamburger BigTasty = new(3, "Big Tasty");
}
It's based on my project JOS.Enumeration. One of the features of that project is that it has a source generator that generates optimized methods for retrieving items by Value
and by Description
.
Value
and Description
needs to be unique. The goal of this post is to create an analyzer that generates a compiler error when a duplicate Value
and/or Description
is detected. The following code should not compile:
public partial class Hamburger : IEnumeration<Hamburger>
{
public static readonly Hamburger Cheeseburger = new (1, "Cheeseburger");
public static readonly Hamburger BigMac = new(2, "Big Mac");
public static readonly Hamburger BigTasty = new(3, "Big Tasty");
// This should generate a compiler error since a Hamburger
// with value 3 has already been declared.
public static readonly Hamburger HappyMeal = new(3, "Happy Meal");
}
The analyzer
This is my first attempt of writing a Roslyn Analyzer so please bare with me.
To create a custom analyzer, we need to inherit from the DiagnosticAnalyzer class.
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class UniqueValuesAnalyzer2 : DiagnosticAnalyzer
{
public override void Initialize(AnalysisContext context)
{
throw new System.NotImplementedException();
}
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
}
The first thing we'll do is to specify which diagnostics our analyzer supports:
private const string UniqueValueDiagnosticId = "JOSEnumeration0001";
private const string UniqueDescriptionDiagnosticId = "JOSEnumeration0002";
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(UniqueValueRule, UniqueDescriptionRule);
private static readonly DiagnosticDescriptor UniqueValueRule = new(
id: UniqueValueDiagnosticId,
title: "Value needs to be unique",
messageFormat: "Value needs to be unique. Value '{0}' has already been added.",
category: "Design",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
private static readonly DiagnosticDescriptor UniqueDescriptionRule = new(
id: UniqueDescriptionDiagnosticId,
title: "Description needs to be unique",
messageFormat: "Description needs to be unique. Description '{0}' has already been added.",
category: "Design",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
We have now created two rules, one for the Value
and one for the Description
property. Let's not care about DRY here.
Now, let's focus on the actual analyzer.
public override void Initialize(AnalysisContext context)
{
var codeAnalysis = GeneratedCodeAnalysisFlags.None;
context.ConfigureGeneratedCodeAnalysis(codeAnalysis);
context.EnableConcurrentExecution();
context.RegisterSymbolAction(c =>
{
}, SymbolKind.NamedType);
}
The important part here is the RegisterSymbolAction
. This callback will fire for each Symbol
that's a NamedType
in our solution (a class, record etc).
Since we only care about types that implement the IEnumeration<>
interface, let's filter out all other types.
context.RegisterSymbolAction(c =>
{
var typeSymbol = (ITypeSymbol)c.Symbol;
if(!typeSymbol.ImplementsIEnumeration())
{
return;
}
}
ImplementsIEnumeration is just an extension method:
internal static bool ImplementsIEnumeration(this ITypeSymbol symbol)
{
return symbol.Interfaces.Any(
x => x.ContainingNamespace.ToString() == "JOS.Enumeration" && x.Name == "IEnumeration");
}
The next thing we'll want to do is to retrive all static fields (our hamburgers).
var items = typeSymbol
.GetMembers()
.Where(x => x.IsStatic &&
x is IFieldSymbol field && SymbolEqualityComparer.Default.Equals(field.Type, typeSymbol))
.Cast<IFieldSymbol>()
.ToArray();
This gives us a list of IFieldSymbols
. The "only" thing left to do now is to extract the actual Value and Description from the IFieldSymbol
.
I've created the following helper method for that:
internal static IReadOnlyCollection<EnumerationItem> ExtractEnumerationItems(
IReadOnlyCollection<IFieldSymbol> fields)
{
var items = new List<EnumerationItem>(fields.Count);
foreach(var field in fields)
{
var syntax = field.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax();
if(syntax is not VariableDeclaratorSyntax variableDeclarationSyntax)
{
continue;
}
var item = CreateEnumerationItem(variableDeclarationSyntax);
items.Add(item);
}
return items;
}
// TODO make this...much less error prone :D
private static EnumerationItem CreateEnumerationItem(VariableDeclaratorSyntax variableSyntax)
{
var objectCreationExpression = (BaseObjectCreationExpressionSyntax)variableSyntax.Initializer!.Value;
var arguments = objectCreationExpression.ArgumentList!.Arguments;
var value = ((LiteralExpressionSyntax)arguments[0].Expression).Token.Value!;
var description = (string)((LiteralExpressionSyntax)arguments[1].Expression).Token.Value!;
var fieldName = variableSyntax.Identifier.Value!.ToString();
return new EnumerationItem(value, description, fieldName, variableSyntax);
}
internal class EnumerationItem
{
internal EnumerationItem(object value, string description, string fieldName, VariableDeclaratorSyntax syntax)
{
Value = value;
Description = description ?? throw new ArgumentNullException(nameof(description));
FieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName));
Syntax = syntax ?? throw new ArgumentNullException(nameof(syntax));
}
public object Value { get; }
public string Description { get; }
public string FieldName { get; }
public VariableDeclaratorSyntax Syntax { get; }
}
The code is really basic and naive, it makes a couple of assumptions:
- The
Value
is always the first argument - The
Description
is always the second argument
To summarize what the method actually does:
- For each field, try to extract a
VariableDeclaratorSyntax
from the field. TheVariableDeclaratorSyntax
will enable us to extract the actual value. - The
VariableDeclaratorSyntax
looks like this:
EqualsValueClauseSyntax EqualsValueClause = new(1, "Cheeseburger")
- We want to extract
1
andCheeseburger
. The following, really naive, code is responsible for that:
var arguments = objectCreationExpression.ArgumentList!.Arguments;
var value = ((LiteralExpressionSyntax)arguments[0].Expression).Token.Value!;
var description = (string)((LiteralExpressionSyntax)arguments[1].Expression).Token.Value!;
- We will then compare all the extracted values and descriptions
var enumerationItems = SourceGenerationHelpers.ExtractEnumerationItems(items);
var values = new HashSet<object>();
var descriptions = new HashSet<string>();
foreach(var enumerationItem in enumerationItems)
{
if(!values.Add(enumerationItem.Value))
{
var diagnostic = CreateUniqueValueDiagnostic(enumerationItem, enumerationItem.Value);
c.ReportDiagnostic(diagnostic);
}
if(!descriptions.Add(enumerationItem.Description))
{
var diagnostic =
CreateUniqueDescriptionDiagnostic(enumerationItem, enumerationItem.Description);
c.ReportDiagnostic(diagnostic);
}
}
- Only thing left to do is to make sure that the error shows up in the correct place in our code:
private static Diagnostic CreateUniqueDescriptionDiagnostic(
EnumerationItem enumerationItem, object propertyValue)
{
return CreateDiagnostic(UniqueDescriptionRule, enumerationItem, propertyValue);
}
private static Diagnostic CreateDiagnostic(
DiagnosticDescriptor descriptor, EnumerationItem enumerationItem, object propertyValue)
{
var tree = enumerationItem.Syntax.SyntaxTree;
var syntax = enumerationItem.Syntax;
var location = Location.Create(tree, syntax.FullSpan);
return Diagnostic.Create(descriptor, location, propertyValue);
}
On our EnumerationItem
, we stored a reference to the VariableDeclaratorSyntax
. It contains a FullSpan
property which contains the start (and end) position of our field declaration.
And that's it!
If we now try to add a new Hamburger with the same Value
, we will get the following compiler error:
Great success!
This was my first implementation of a Roslyn Analyzer, and it's just hacked together to "make it work". It only took me an hour, and it was my first time! I've always thought that analyzers are something hard and scary. Turns out...it's not!
I will add this analyzer to the JOS.Enumeration package. However, I will make sure to make it a bit tidier before releasing it.
The full analyer looks like this:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
namespace JOS.Enumeration.SourceGenerator;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class UniqueValuesAnalyzer : DiagnosticAnalyzer
{
private const string UniqueValueDiagnosticId = "JOSEnumeration0001";
private const string UniqueDescriptionDiagnosticId = "JOSEnumeration0002";
public override void Initialize(AnalysisContext context)
{
var codeAnalysis = GeneratedCodeAnalysisFlags.None;
context.ConfigureGeneratedCodeAnalysis(codeAnalysis);
context.EnableConcurrentExecution();
context.RegisterSymbolAction(c =>
{
var typeSymbol = (ITypeSymbol)c.Symbol;
if(!typeSymbol.ImplementsIEnumeration())
{
return;
}
var items = typeSymbol
.GetMembers()
.Where(x => x.IsStatic &&
x is IFieldSymbol field &&
SymbolEqualityComparer.Default.Equals(field.Type, typeSymbol))
.Cast<IFieldSymbol>()
.ToArray();
var enumerationItems = SourceGenerationHelpers.ExtractEnumerationItems(items);
var values = new HashSet<object>();
var descriptions = new HashSet<string>();
foreach(var enumerationItem in enumerationItems)
{
if(!values.Add(enumerationItem.Value))
{
// TODO extract the parameter exact position
var diagnostic = CreateUniqueValueDiagnostic(enumerationItem, enumerationItem.Value);
c.ReportDiagnostic(diagnostic);
}
if(!descriptions.Add(enumerationItem.Description))
{
// TODO extract the parameter exact position
var diagnostic =
CreateUniqueDescriptionDiagnostic(enumerationItem, enumerationItem.Description);
c.ReportDiagnostic(diagnostic);
}
}
}, SymbolKind.NamedType);
}
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(UniqueValueRule, UniqueDescriptionRule);
private static readonly DiagnosticDescriptor UniqueValueRule = new(
id: UniqueValueDiagnosticId,
title: "Value needs to be unique",
messageFormat: "Value needs to be unique. Value '{0}' has already been added.",
category: "Design",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
private static readonly DiagnosticDescriptor UniqueDescriptionRule = new(
id: UniqueDescriptionDiagnosticId,
title: "Description needs to be unique",
messageFormat: "Description needs to be unique. Description '{0}' has already been added.",
category: "Design",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
private static Diagnostic CreateUniqueValueDiagnostic(EnumerationItem enumerationItem, object propertyValue)
{
return CreateDiagnostic(UniqueValueRule, enumerationItem, propertyValue);
}
private static Diagnostic CreateUniqueDescriptionDiagnostic(
EnumerationItem enumerationItem, object propertyValue)
{
return CreateDiagnostic(UniqueDescriptionRule, enumerationItem, propertyValue);
}
private static Diagnostic CreateDiagnostic(
DiagnosticDescriptor descriptor, EnumerationItem enumerationItem, object propertyValue)
{
var tree = enumerationItem.Syntax.SyntaxTree;
var syntax = enumerationItem.Syntax;
var location = Location.Create(tree, syntax.FullSpan);
return Diagnostic.Create(descriptor, location, propertyValue);
}
}