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 diesem Artikel:

Incremental Roslyn Source Generators in .NET 6: Adapt Code Generation Based on Project Dependencies – Part 5
Pawel Gerr ist Architekt und Consultant bei Thinktecture. Er hat sich auf .NET Core Backends spezialisiert und kennt Entity Framework von vorne bis hinten.

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.
Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Diese Artikel könnten Sie interessieren
Unterschiede
.NET
Blazor WebAssembly vs. Blazor Server – Welche Unterschiede gibt es und wann wähle ich was?

Blazor WebAssembly vs. Blazor Server – Welche Unterschiede gibt es und wann wähle ich was?

Das Blazor Framework von Microsoft gibt es inzwischen in drei "Geschmacksrichtungen". Die erste ist Blazor WebAssembly, die zweite Blazor Server, und zu guter Letzt gibt es noch Blazor Hybrid. In diesem Artikel wollen wir uns die zwei "echten", also Browser-basierten, Web-Anwendungs-Szenarien WebAssembly und Server anschauen.
04.07.2022
Three different textured walls
.NET
Dependency Injection Scopes in Blazor

Dependency Injection Scopes in Blazor

The dependency injection system is a big part of how modern ASP.NET Core works internally: It provides a flexible solution for developers to structure their projects, decouple their dependencies, and control the lifetimes of the components within an application. In Blazor - a new part of ASP.NET Core - however, the DI system feels a bit odd, and things seem to work a bit differently than expected. This article will explain why this is not only a feeling but indeed the case in the first place and how to handle the differences in order to not run into problems later on.
31.05.2022
.NET
Asynchrone Operationen: Blazor WebAssembly für Angular-Entwickler – Teil 5 [Screencast]

Asynchrone Operationen: Blazor WebAssembly für Angular-Entwickler – Teil 5 [Screencast]

Eine Webanwendung will natürlich auch mit Daten gefüttert werden. Doch diese müssen irgendwo her kommen. Nichts liegt näher als diese von einer Web API zu laden. Dieser Screencast zeigt, wie asynchrone Operationen in Blazor funktionieren und welche gravierenden Unterschiede es zu Angular gibt.
26.05.2022
.NET
Typings: Blazor WebAssembly für Angular-Entwickler – Teil 4 [Screencast]

Typings: Blazor WebAssembly für Angular-Entwickler – Teil 4 [Screencast]

C# und TypeScript entstammen der Feder der selben Person. Doch sind sie deshalb auch gleich? In diesem Teil der Screencast-Serie erfahren Sie, wie mit Typen in den beiden Programmiersprachen verfahren wird und welche Unterschiede es gibt.
19.05.2022
.NET
Bindings: Blazor WebAssembly für Angular-Entwickler – Teil 3 [Screencast]

Bindings: Blazor WebAssembly für Angular-Entwickler – Teil 3 [Screencast]

Wer Komponenten einsetzt, steht früher oder später vor der Fragestellung, wie man Daten an die Komponente übergibt oder auf Ereignisse einer Komponente reagiert. In diesem Screencast wird gezeigt wie Bindings bei Komponenten funktionieren, also wie eine Komponente Daten von außerhalb benutzen und Rückmeldung bei Aktionen geben kann.
12.05.2022
.NET
Incremental Roslyn Source Generators in .NET 6: Increasing Performance through Harnessing of the Memoization – Part 4

Incremental Roslyn Source Generators in .NET 6: Increasing Performance through Harnessing of the Memoization – Part 4

In Part 1 of this series we've implemented a simple Incremental Source Generator. Although we looked at all mandatory phases of an IIncrementalGenerator, still, the consideration was quite superficial. One of the biggest advantages of the new API, which makes the Source Generator an incremental one, is the built-in memoization, i.e. caching. To take full advantage from it, we have to make some preparations so Roslyn knows what to cache and how to compare the results of each phase.
10.05.2022