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
.NET
Incremental Roslyn Source Generators: High-Level API – ForAttributeWithMetadataName – Part 8

Incremental Roslyn Source Generators: High-Level API – ForAttributeWithMetadataName – Part 8

With the version 4.3.1 of Microsoft.CodeAnalysis.* Roslyn provides a new high-level API - the method "ForAttributeWithMetadataName". Although it is just 1 method, still, it addresses one of the biggest performance issue with Source Generators.
16.05.2023
.NET
Integrating AI Power into Your .NET Applications with the Semantic Kernel Toolkit – an Early View

Integrating AI Power into Your .NET Applications with the Semantic Kernel Toolkit – an Early View

With the rise of powerful AI models and services, questions come up on how to integrate those into our applications and make reasonable use of them. While other languages like Python already have popular and feature-rich libraries like LangChain, we are missing these in .NET and C#. But there is a new kid on the block that might change this situation. Welcome Semantic Kernel by Microsoft!
03.05.2023
.NET
.NET 7 Performance: Regular Expressions – Part 2

.NET 7 Performance: Regular Expressions – Part 2

There is this popular quote by Jamie Zawinski: Some people, when confronted with a problem, think "I know, I'll use regular expressions." Now they have two problems."

In this second article of our short performance series, we want to look at the latter one of those problems.
25.04.2023
.NET
.NET 7 Performance: Introduction and Runtime Optimizations – Part 1

.NET 7 Performance: Introduction and Runtime Optimizations – Part 1

.NET 7 is fast. Superfast. All the teams at Microsoft working on .NET are keen to improve the performance and do so every year with each new .NET release. Though this time the achievements are really impressive. In this series of short articles, we want to explore some of the most significant performance updates in .NET and look at how that may affect our own projects. This first article is taking a deep look under the hood of the compiler and the runtime to look for some remarkably interesting and significant updates.
28.03.2023
.NET
Incremental Roslyn Source Generators: Using Additional Files – Part 7

Incremental Roslyn Source Generators: Using Additional Files – Part 7

In the previous article the Source Generator itself needed a 3rd-party library Newtonsoft.Json in order to generate new source code. The JSON-strings were hard-coded inside the Source Generator for simplicity reasons. In this article we will see how to process not just .NET code, but also other files, like JSON or XML.
21.03.2023
.NET
Understanding and Controlling the Blazor WebAssembly Startup Process

Understanding and Controlling the Blazor WebAssembly Startup Process

There are a lot of things going on in the background, when a Blazor WebAssembly application is being started. In some cases you might want to take a bit more control over that process. One example might be the wish to display a loading screen for applications that take some time for initial preparation, or when users are on a slow internet connection. However, in order to control something, we need to understand what is happening first. This article takes you down the rabbit hole of how a Blazor WASM application starts up.
07.03.2023