Incremental Roslyn Source Generators in .NET 6: Adapt Code Generation Based on Project Dependencies – Part 5

The Roslyn Source Generator, implemented in the previous articles of the series, emits some C# code without looking at the dependencies of the current .NET (Core) project. In this article our DemoSourceGenerator should implement a JsonConverter, but only if the corresponding library (e.g. Newtonsoft.Json) is referenced by the project.

In this article:

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

More information about the Smart Enums and the source code can be found on GitHub:

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<ICodeGenerator>
{
   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 @$"// <auto-generated />

// 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();
   }
}

				
			
The only change worth mentioning in 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<DemoEnumInfo>()
                             .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.

We will use a workaround for the issue mentioned above, i.e. instead of using context.MetadataReferencesProvider we will use context.GetMetadataReferencesProvider().
				
					using System.Collections.Immutable;
using Microsoft.CodeAnalysis;

namespace DemoSourceGenerator;

public static class IncrementalGeneratorInitializationContextExtensions
{
   public static IncrementalValuesProvider<MetadataReference> 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<MetadataReference> metadataValuesProvider)
         return metadataValuesProvider;

      if (metadataProvider is IncrementalValueProvider<MetadataReference> 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)}>.'");
   }
}

				
			
From the 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<ICodeGenerator>.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<ModuleInfo> 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<ModuleInfo>();
   }
}
				
			

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<ICodeGenerator>) 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<DemoEnumInfo>
{
   public string? Namespace { get; }
   public string Name { get; }
   public bool HasNameProperty { get; }
   public IReadOnlyList<string> 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 @$"// <auto-generated />
#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<string?>(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

Using the MetadataReferencesProvider we can generate different code according to the current dependencies of the project.
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