Article series
- Code sharing 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 information about the Smart Enums and the source code can be found on GitHub:
- Smart Enums
- Source Code (see commits starting with message “Part 5”)
I highly recommend reading Part 1 and Part 4 because we will modify the DemoSourceGenerator
, which is implemented and modified in the preivious articles.
In this article, we will generate a JSON converter for our Smart Enums. The Newtonsoft.Json will be our JSON serializer. I choose Newtonsoft.Json over System.Text.Json because the latter comes with the framework and is referenced by default, i.e., it doesn’t suit as an example very well. The Newtonsoft.Json is not referenced by default, so we can install and uninstall the NuGet package to see the behavior change of the Source Generator.
Preparation: Moving Code Generation to its own Component
For better code structure, we will move the actual C# code generation out of the DemoSourceGenerator
to its own component DemoCodeGenerator
, which implements a new interface ICodeGenerator
. Every code generator must implement the methods Equals
and GetHashCode
because we need a valid equality comparison later on. Furthermore, we will need a FileHintSuffix
because the file hints must be unique.
namespace DemoSourceGenerator;
public interface ICodeGenerator : IEquatable
{
string? FileHintSuffix { get; }
string Generate(DemoEnumInfo enumInfo);
}
The method GenerateCode(DemoEnumInfo enumInfo)
is moved out of the DemoSourceGenerator
to DemoCodeGenerator
.
namespace DemoSourceGenerator;
public sealed class DemoCodeGenerator : ICodeGenerator
{
public static readonly DemoCodeGenerator Instance = new();
private static int _counter;
public string? FileHintSuffix => null;
public string Generate(DemoEnumInfo enumInfo)
{
var ns = enumInfo.Namespace;
var name = enumInfo.Name;
return @$"//
// generation counter: {Interlocked.Increment(ref _counter)}
using System.Collections.Generic;
{(ns is null ? null : $@"namespace {ns}
{{")}
partial class {name}
{{
private static IReadOnlyList<{name}> _items;
public static IReadOnlyList<{name}> Items => _items ??= GetItems();
private static IReadOnlyList<{name}> GetItems()
{{
return new[] {{ {String.Join(", ", enumInfo.ItemNames)} }};
}}
}}
{(ns is null ? null : @"}
")}";
}
public override bool Equals(object? obj)
{
return obj is DemoCodeGenerator;
}
public bool Equals(ICodeGenerator other)
{
return other is DemoCodeGenerator;
}
public override int GetHashCode()
{
return GetType().GetHashCode();
}
}
DemoSourceGenerator
is the line var code = GenerateCode(enumInfo)
that is changed to var code = DemoCodeGenerator.Instance.Generate(enumInfo)
.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace DemoSourceGenerator;
[Generator]
public class DemoSourceGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var enumTypes = context.SyntaxProvider
.CreateSyntaxProvider(CouldBeEnumerationAsync, GetEnumInfoOrNull)
.Where(type => type is not null)!
.Collect()
.SelectMany((enumInfos, _) => enumInfos.Distinct());
context.RegisterSourceOutput(enumTypes, GenerateCode!);
}
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);
}
private static string? ExtractName(NameSyntax? name)
{
return name switch
{
SimpleNameSyntax ins => ins.Identifier.Text,
QualifiedNameSyntax qns => qns.Right.Identifier.Text,
_ => null
};
}
private static DemoEnumInfo? GetEnumInfoOrNull(
GeneratorSyntaxContext context,
CancellationToken cancellationToken)
{
var classDeclaration = (ClassDeclarationSyntax)context.Node.Parent!.Parent!;
var type = ModelExtensions.GetDeclaredSymbol(context.SemanticModel, classDeclaration)
as ITypeSymbol;
return type is null || !IsEnumeration(type) ? null : new DemoEnumInfo(type);
}
public static bool IsPartial(ClassDeclarationSyntax classDeclaration)
{
return classDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
}
public static bool IsEnumeration(ISymbol type)
{
return type.GetAttributes()
.Any(a => a.AttributeClass?.Name == "EnumGenerationAttribute" &&
a.AttributeClass.ContainingNamespace is
{
Name: "DemoLibrary",
ContainingNamespace.IsGlobalNamespace: true
});
}
private static void GenerateCode(SourceProductionContext context, DemoEnumInfo enumInfo)
{
var code = DemoCodeGenerator.Instance.Generate(enumInfo);
var ns = enumInfo.Namespace is null ? null : $"{enumInfo.Namespace}.";
context.AddSource($"{ns}{enumInfo.Name}.g.cs", code);
}
}
Let DemoCodeGenerator depend on "DemoLibrary.dll"
It may not be obvious why the “main” code generator (i.e. the DemoCodeGenerator
) should depend on anything, but it becomes more clear when adding further code generators. Otherwise, the DemoCodeGenerator
would need special handling, which usually leads to more complexity.
For the generation of the code that depends on some specific libraries, we have to know all referenced assemblies (or rather modules). For this purpose, we can use the property MetadataReferencesProvider
on the context
provided by Roslyn.
[Generator]
public class DemoSourceGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var references = context.MetadataReferencesProvider
...
Please note: The SDK 6.0.300 introduced a breaking change (GitHub: 58059), leading to a runtime exception when using Microsoft.CodeAnalysis 4.0.1 or 4.1.0 with SDK 6.0.300.
There is a workaround mentioned on GitHub: 61333.
context.MetadataReferencesProvider
we will use context.GetMetadataReferencesProvider()
.
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
namespace DemoSourceGenerator;
public static class IncrementalGeneratorInitializationContextExtensions
{
public static IncrementalValuesProvider GetMetadataReferencesProvider(
this IncrementalGeneratorInitializationContext context)
{
var metadataProviderProperty = context.GetType()
.GetProperty(nameof(context.MetadataReferencesProvider))
?? throw new Exception($"The property '{nameof(context.MetadataReferencesProvider)}' not found");
var metadataProvider = metadataProviderProperty.GetValue(context);
if (metadataProvider is IncrementalValuesProvider metadataValuesProvider)
return metadataValuesProvider;
if (metadataProvider is IncrementalValueProvider metadataValueProvider)
return metadataValueProvider.SelectMany(static (reference, _) => ImmutableArray.Create(reference));
throw new Exception($"The '{nameof(context.MetadataReferencesProvider)}' is neither an 'IncrementalValuesProvider<{nameof(MetadataReference)}>' nor an 'IncrementalValueProvider<{nameof(MetadataReference)}>.'");
}
}
MetadataReferencesProvider
we get all references of the current project. In our demo, the method TryGetCodeGenerator
returns only 1 ICodeGenerator
per reference, although a reference can have multiple modules of interest in theory. Return a collection of code generators to be more accurate.
[Generator]
public class DemoSourceGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var generators = context.GetMetadataReferencesProvider()
.SelectMany(static (reference, _) => TryGetCodeGenerator(reference, out var factory)
? ImmutableArray.Create(factory)
: ImmutableArray.Empty)
.Collect();
...
}
private static bool TryGetCodeGenerator(
MetadataReference reference,
[MaybeNullWhen(false)] out ICodeGenerator codeGenerator)
{
...
}
For the actual analysis of the dependencies, we can’t use the MetadataReference
itself but the modules inside it. At this point, we have to differentiate between a referenced DLL and a project reference.
using Microsoft.CodeAnalysis;
namespace DemoSourceGenerator;
public readonly struct ModuleInfo
{
public string Name { get; }
public Version Version { get; }
public ModuleInfo(string name, Version version)
{
Name = name;
Version = version;
}
}
public static class MetadataReferenceExtensions
{
public static IEnumerable GetModules(this MetadataReference metadataReference)
{
// Project reference (ISymbol)
if (metadataReference is CompilationReference compilationReference)
{
return compilationReference.Compilation.Assembly.Modules
.Select(m => new ModuleInfo(
m.Name,
compilationReference.Compilation.Assembly.Identity.Version));
}
// DLL
if (metadataReference is PortableExecutableReference portable
&& portable.GetMetadata() is AssemblyMetadata assemblyMetadata)
{
return assemblyMetadata.GetModules()
.Select(m => new ModuleInfo(
m.Name,
m.GetMetadataReader().GetAssemblyDefinition().Version));
}
return Array.Empty();
}
}
With the extension method GetModules
we can iterate over the modules to check whether there are some dependencies we are interested in.
The method GenerateCode
iterates over the code generators and emits a file using the new FileHintSuffix
.
[Generator]
public class DemoSourceGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var enumTypes = ...;
var generators = ...;
context.RegisterSourceOutput(enumTypes.Combine(generators), GenerateCode);
}
private static bool TryGetCodeGenerator(
MetadataReference reference,
[MaybeNullWhen(false)] out ICodeGenerator codeGenerator)
{
foreach (var module in reference.GetModules())
{
switch (module.Name)
{
case "DemoLibrary.dll":
codeGenerator = DemoCodeGenerator.Instance;
return true;
}
}
codeGenerator = null;
return false;
}
private static void GenerateCode(
SourceProductionContext context,
(DemoEnumInfo, ImmutableArray) tuple)
{
var (enumInfo, generators) = tuple;
if (generators.IsDefaultOrEmpty)
return;
foreach (var generator in generators.Distinct())
{
var ns = enumInfo.Namespace is null ? null : $"{enumInfo.Namespace}.";
var code = generator.Generate(enumInfo);
if (!String.IsNullOrWhiteSpace(code))
context.AddSource($"{ns}{enumInfo.Name}{generator.FileHintSuffix}.g.cs", code);
}
}
Code Generator for JsonConverter
Before writing the JSON converter, we have to think about how the Smart Enum should be (de)serialized. For the sake of simplicity, let’s assume the Smart Enums will have a property Name
of type string
, if not, then we skip the generation of the converter entirely.
For the correct handling of the new requirement by the Roslyn cache, we have to extend the DemoEnumInfo
. (see Part 4 for more information)
using Microsoft.CodeAnalysis;
namespace DemoSourceGenerator;
public sealed class DemoEnumInfo : IEquatable
{
public string? Namespace { get; }
public string Name { get; }
public bool HasNameProperty { get; }
public IReadOnlyList ItemNames { get; }
public DemoEnumInfo(ITypeSymbol type)
{
Namespace = type.ContainingNamespace.IsGlobalNamespace
? null
: type.ContainingNamespace.ToString();
Name = type.Name;
HasNameProperty = type.GetMembers()
.Any(m => m.Name == "Name"
&& m is IPropertySymbol property
&& property.Type.SpecialType == SpecialType.System_String);
ItemNames = GetItemNames(type);
}
...
public bool Equals(DemoEnumInfo? other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return Namespace == other.Namespace
&& Name == other.Name
&& HasNameProperty == other.HasNameProperty
&& ItemNames.EqualsTo(other.ItemNames);
}
public override int GetHashCode()
{
unchecked
{
var hashCode = (Namespace != null ? Namespace.GetHashCode() : 0);
hashCode = (hashCode * 397) ^ Name.GetHashCode();
hashCode = (hashCode * 397) ^ HasNameProperty.GetHashCode();
hashCode = (hashCode * 397) ^ ItemNames.ComputeHashCode();
return hashCode;
}
}
}
All preparations are made for adding further code generators. The generation of the JsonConverter
will be done by the new NewtonsoftJsonSourceGenerator
. If the Smart Enum has no property Name
then no code is generated. Furthermore, the code generator provides a FileHintSuffix
, so the file hints are unique.
namespace DemoSourceGenerator;
public sealed class NewtonsoftJsonSourceGenerator : ICodeGenerator
{
public static readonly NewtonsoftJsonSourceGenerator Instance = new();
private static int _counter;
public string FileHintSuffix => ".NewtonsoftJson";
public string Generate(DemoEnumInfo enumInfo)
{
if (!enumInfo.HasNameProperty)
return String.Empty;
var ns = enumInfo.Namespace;
var name = enumInfo.Name;
return @$"//
#nullable enable
// generation counter: {Interlocked.Increment(ref _counter)}
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
{(ns is null ? null : $@"namespace {ns}
{{")}
[JsonConverterAttribute(typeof({name}NewtonsoftJsonConverter))]
partial class {name}
{{
public class {name}NewtonsoftJsonConverter : JsonConverter<{name}>
{{
public override void WriteJson(JsonWriter writer,
{name}? value,
JsonSerializer serializer)
{{
if (value is null)
{{
writer.WriteNull();
}}
else
{{
writer.WriteValue(value.Name);
}}
}}
public override {name}? ReadJson(JsonReader reader,
Type objectType,
{name}? existingValue,
bool hasExistingValue,
JsonSerializer serializer)
{{
var name = serializer.Deserialize(reader);
return name is null
? null
: {name}.Items.SingleOrDefault(c => c.Name == name);
}}
}}
}}
{(ns is null ? null : @"}
")}";
}
public override bool Equals(object? obj)
{
return obj is NewtonsoftJsonSourceGenerator;
}
public bool Equals(ICodeGenerator other)
{
return other is NewtonsoftJsonSourceGenerator;
}
public override int GetHashCode()
{
return GetType().GetHashCode();
}
}
The last step is to add the code generator to TryGetCodeGenerator
. For JSON converter, we are not just checking for the DLL but for the major version as well because our code generator generates a JsonConverter
which requires a newer version of Newtonsoft.Json.
[Generator]
public class DemoSourceGenerator : IIncrementalGenerator
{
...
private static bool TryGetCodeGenerator(
MetadataReference reference,
[MaybeNullWhen(false)] out ICodeGenerator codeGenerator)
{
foreach (var module in reference.GetModules())
{
switch (module.Name)
{
case "DemoLibrary.dll":
codeGenerator = DemoCodeGenerator.Instance;
return true;
case "Newtonsoft.Json.dll" when module.Version.Major >= 11:
codeGenerator = NewtonsoftJsonSourceGenerator.Instance;
return true;
}
}
codeGenerator = null;
return false;
}
...
Summary
MetadataReferencesProvider
we can generate different code according to the current dependencies of the project.