Incremental Roslyn Source Generators In .NET 6: Code Sharing Of The Future – Part 1

The Roslyn Source Generators, which came with the .NET 5 SDK, are probably one of the best features in the last few years. They allow us to improve the way we share code today by generating it on-the-fly during development instead of shipping fix set of components, helpers and base classes relying heavily on Reflection. With .NET 6 SDK, Microsoft gave us Incremental Roslyn Source Generators for more efficient and resource-saving implementation of Source Generators.

In this article:

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

Code Sharing Today

The most common approach for sharing code/functionality is providing base/helper classes or components for reuse in other projects. Their APIs and behaviors are relatively static, i.e. the components cannot react to the needs of developers like adding/removing a feature or renaming a property on-the-fly. To make them more dynamic, the libraries and frameworks usually rely on Reflection, which has several limitations and some performance penalties. Sure, Source Generators also have limitations, but their capabilities expand way beyond the boundaries of Reflection. At first glance, it may look like Source Generators are a replacement for Reflection, but this is only partially true. There are use cases more suitable for Source Generators and others that are easier to implement using Reflection. If it is possible to implement a feature with any of the mentioned tools, we suggest using one which best fulfills the (rather technical) requirements. Here are a few pros and cons to base your decision-making on.

Reflection:

  • Pro: (usually) faster to implement
  • Pro: easier to learn
  • Con: fewer capabilities
  • Con: more performance penalties

Source Generators:

  • Pro: more capabilities
  • Pro: fewer performance penalties
  • Con: (usually) require more development time
  • Con: steeper learning curve

Note: I skipped T4 templates and other external code generation tools entirely. In general, these tools are used to generate new files out of (relatively static) XML & co., i. e. they don’t provide an abstract syntax tree of our code for analysis and don’t react to the changes in our C# code. Long story short, there are use cases where T4 and co. are more suitable than Reflection and Roslyn Source Generators.

Further Improving the Developer Experience

The development experience can be improved when using Source Generators rather than Reflection. Imagine, whole classes, methods, and properties magically appear out of nowhere and are ready to be used. We can further improve the experience by using not only the (Incremental) Source Generators but Roslyn Analyzers, and even the Roslyn Code Fixes as icing on the cake. With Analyzers, we can guide the developers in the right direction by emitting compiler warnings and errors and highlight those parts of the code which are incorrect or probably not intended the way they are. If the emitted warning or error can be fixed automatically, a Code Fix is the right tool to use. The developer can apply the fix with just one click.

To be able to extend a custom type with additional members like fields, properties or methods, the type must be partial. So, one of the use cases for a Roslyn Analyzer is to check whether our type is partial or not, and if not, then a Roslyn Code Fix can provide a one-click-fix for adding the missing keyword. Until the type is partial, the Source Generator should not emit any C# code because we would end up with two different classes having the same name which leads to a compiler error and may confuse the developer.

Creation of a New Incremental Source Generator

In this article, we want to create a simple Incremental Source Generator for generating so-called Smart Enums.

More background info about Smart Enums and their source code can be found on GitHub:

Please note: First, we set up the Source Generator to be referenced by another project directly. Any adjustments required for packaging as a NuGet package can be made later.

A Source Generator itself is a .NET class, so let’s create a new solution and a library project DemoSourceGenerator. The content of the project file DemoSourceGenerator.csproj should be like this:

				
					<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>10.0</LangVersion>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces"
                          Version="4.0.1"
                          PrivateAssets="all" />
    </ItemGroup>

</Project>
				
			

The (empty) project is targeting netstandard2.0 and is referencing Microsoft.CodeAnalysis.CSharp.Workspaces. The C# version 10.0Nullable = enable and ImplicitUsings = enable are optional and are set for convenience reasons.

The target framework of the project containing the Source Generator should be set to netstandard2.0, otherwise some IDEs may refuse loading the DLL. For example, JetBrains Rider 2021.3.2 has no issues with netstandard2.1 but Visual Studio 2022 (17.0.4) demands netstandard2.0.

Next, create a new class DemoSourceGenerator.cs which will be the actual Source Generator.

				
					using Microsoft.CodeAnalysis;

namespace DemoSourceGenerator;

[Generator]
public class DemoSourceGenerator : IIncrementalGenerator
{
   public void Initialize(IncrementalGeneratorInitializationContext context)
   {
   }
}
				
			

There are two requirements for making a C# class to an Incremental Source Generator:

  • the interface IIncrementalGenerator must be implemented
  • the class has to be flagged with the GeneratorAttribute

We now have a fully functional Source Generator which does absolutely nothing besides wasting CPU cycles.

Sample Console Application

Let’s create a new console application called DemoConsoleApplication for testing our Source Generator. The DemoConsoleApplication should reference the DemoSourceGenerator, but this project reference will be slightly different from usual.

The content of the DemoConsoleApplication.csproj should be:

				
					<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <LangVersion>10.0</LangVersion>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
    </PropertyGroup>

    <ItemGroup>
        <ProjectReference Include="..\DemoSourceGenerator\DemoSourceGenerator.csproj"
                          ReferenceOutputAssembly="false"
                          OutputItemType="Analyzer" />
    </ItemGroup>

</Project>

				
			

The differences mentioned above are two new attributes, which have to be added manually:

  • ReferenceOutputAssembly="false": we don’t need the DemoSourceGenerator.dll in the folder bin of the DemoConsoleApplication
  • OutputItemType="Analyzer": the Source Generators are packaged and deployed the same way as the Roslyn Analyzers

The Program.cs can stay as it is, we will get back to it later.

First Dry Run (or Rather Crash)

Due to missing implementation in the DemoSourceGenerator, we cannot check whether our Source Generator is working correctly. To force the compiler to give us some feedback, we will use an unorthodox approach and throw an exception in the method Initialize.

				
					[Generator]
public class DemoSourceGenerator : IIncrementalGenerator
{
   public void Initialize(IncrementalGeneratorInitializationContext context)
   {
      throw new Exception("Test exception!"); // delete me after test
   }
}
				
			

After Rebuilding the solution, we should see this compiler warning:

				
					 Generator 'DemoSourceGenerator' failed to initialize.
  It will not contribute to the output and compilation errors may occur as a result.
  Exception was of type 'Exception' with message 'Test exception!'
				
			

If your IDE (Visual Studio, Rider, etc.) doesn’t show anything, try to build the solution or the DemoConsoleApplication on the console: dotnet build --no-incremental

An (incremental) Build of the solution is usually not sufficient, make a full Rebuild after any change in the Source Generator.

Delete the exception after a successful crash of the DemoSourceGenerator.

Smart Enumification

In this article, we want to extend an existing class by creating the new static property Items, which provides all items of the enumeration.

Start with creating a file ProductCategory.cs in DemoConsoleApplication.

				
					namespace DemoConsoleApplication;

public partial class ProductCategory
{
   public static readonly ProductCategory Fruits = new("Fruits");
   public static readonly ProductCategory Dairy = new("Dairy");

   public string Name { get; }

   private ProductCategory(string name)
   {
      Name = name;
   }
}
				
			

We make the following assumptions to keep the demo as simple as possible:

  • the class is partial
  • all items are public static readonly fields
  • all items are of the same type as the enumeration

All assumptions should be implemented as Roslyn Analyzers to guide the developer. Take a look at my GitHub repo for some examples: PawelGerr/Thinktecture.Runtime.Extensions.

What we expect from the Source Generator is the following output:

				
					// <auto-generated />

using System.Collections.Generic;

namespace DemoConsoleApplication
{
   partial class ProductCategory
   {
      private static IReadOnlyList<ProductCategory> _items;
      public static IReadOnlyList<ProductCategory> Items => _items ??= GetItems();

      private static IReadOnlyList<ProductCategory> GetItems()
      {
         return new[] { Fruits, Dairy };
      }
   }
}
				
			

Marking Relevant Types

We recommend having some kind of marker, so the Source Generator knows what classes are Smart Enums and which are not. Alternatively, we can analyze all classes and try to figure out whether the class is relevant or not, or just turn all classes into enumerations, but we don’t want to do this. A marker could be anything: a specific interface, a base class, or an attribute. In this article, we will use an attribute because an attribute can provide further metadata, if necessary, to control the source code generation.

Hint: Take a look at the EnumGenerationAttribute and its usages to see how the code generation can be controlled by an attribute.

What’s left is to find a suitable place for the marker-attribute mentioned above. The DemoConsoleApplication is just a sample application that will not be packaged and deployed anywhere, so it is not the right place. Theoretically, we could put the attribute along with the DemoSourceGenerator but I don’t want to mix up the code referenced by other projects with the code of the Source Generators, Analyzers, and Code Fixes.

Create another library project DemoLibrary, and add a new class EnumGenerationAttribute. The DemoLibrary.csproj targets netstandard2.0, but unlike the DemoSourceGenerator.csproj, the target framework doesn’t matter much.

				
					<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>10.0</LangVersion>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
    </PropertyGroup>

</Project>
				
			

In this demo, the EnumGenerationAttribute.cs is applicable to classes only.

				
					namespace DemoLibrary;

[AttributeUsage(AttributeTargets.Class)]
public class EnumGenerationAttribute : Attribute
{
}

				
			

Now, the DemoConsoleApplication can reference the project DemoLibrary to put the EnumGenerationAttribute on ProductCategory.

				
					using DemoLibrary;

namespace DemoConsoleApplication;

[EnumGeneration]
public partial class ProductCategory
{
   ....

				
			

Source Code Generator

Finally, we are through with the preparations and ready to implement the DemoSourceGenerator.

				
					[Generator]
public class DemoSourceGenerator : IIncrementalGenerator
{
   public void Initialize(IncrementalGeneratorInitializationContext context)
   {
      // TODO
				
			

Collecting Relevant Types

First, we have to collect all classes we are interested in. In our case, we are interested in classes with the EnumGenerationAttribute. To collect the corresponding classes, we subscribe to the provided context.SyntaxProvider which notifies us when there are new code changes available. The syntax provider requires 2 callbacks: a predicate and a transformation.

				
					 public void Initialize(IncrementalGeneratorInitializationContext context)
   {
      var enumTypes = context.SyntaxProvider
                          .CreateSyntaxProvider(CouldBeEnumerationAsync, GetEnumTypeOrNull)
                          .Where(type => type is not null)
                          .Collect();
				
			

The predicate CouldBeEnumerationAsync is a method which will be called very often to check whether the provided SyntaxNode is relevant for us or not. A SyntaxNode is a fragment of C# code, and this could be something big as a class declaration or as small as a property getter.

The predicate will be called virtually on any code change, i.e. it must be as fast and resource-saving as possible.

The predicate checks for the AttributeSyntax and its name. The name of the attribute must be either EnumGenerationAttribute or its short form EnumGeneration but we have to extract the name first. The extraction of the name is required in case the attribute is specified with its namespace, like [DemoLibrary.EnumGeneration] instead of [EnumGeneration]. In this stage we don’t have precise type information, i.e. if we find an attribute with the name EnumGenerationAttribute then it could be ours or some other attribute with the same class name but a different namespace.

				
					 private static bool CouldBeEnumerationAsync(
      SyntaxNode syntaxNode,
      CancellationToken cancellationToken)
   {
      if (syntaxNode is not AttributeSyntax attribute)
         return false;

      var name = ExtractName(attribute.Name);

      return name is "EnumGeneration" or "EnumGenerationAttribute";
   }

   private static string? ExtractName(NameSyntax? name)
   {
      return name switch
      {
         SimpleNameSyntax ins => ins.Identifier.Text,
         QualifiedNameSyntax qns => qns.Right.Identifier.Text,
         _ => null
      };
   }
				
			

If the predicate CouldBeEnumerationAsync evaluates to true then the transformation GetEnumTypeOrNull is called for further analysis. The provided context.Node is the AttributeSyntax we checked in CouldBeEnumerationAsync. In the transformation stage we get the context.SemanticModel to be able to fetch the type information (ITypeSymbol) from. Having the type information, we now able to check the namespace and the class name. If everything matches then we return the corresponding ITypeSymbol to the next stage, i.e. the code generation, otherwise we return null. The value null is filtered out by .Where(type => type is not null).

				
					   private static ITypeSymbol? GetEnumTypeOrNull(
      GeneratorSyntaxContext context,
      CancellationToken cancellationToken)
   {
      var attributeSyntax = (AttributeSyntax)context.Node;

      // "attribute.Parent" is "AttributeListSyntax"
      // "attribute.Parent.Parent" is a C# fragment the attributes are applied to
      if (attributeSyntax.Parent?.Parent is not ClassDeclarationSyntax classDeclaration)
         return null;

      var type = context.SemanticModel.GetDeclaredSymbol(classDeclaration) as ITypeSymbol;

      return type is null || !IsEnumeration(type) ? null : type;
   }

   private static bool IsEnumeration(ISymbol type)
   {
      return type.GetAttributes()
                 .Any(a => a.AttributeClass?.Name == "EnumGenerationAttribute" &&
                           a.AttributeClass.ContainingNamespace is
                           {
                              Name: "DemoLibrary",
                              ContainingNamespace.IsGlobalNamespace: true
                           });
   }

				
			

C# Code Generation

After gathering the types, we can start generating C# code by providing the types and a callback (GenerateCode) to the method context.RegisterSourceOutput.

				
					   public void Initialize(IncrementalGeneratorInitializationContext context)
   {
      var enumTypes = context.SyntaxProvider
                          .CreateSyntaxProvider(CouldBeEnumerationAsync, GetEnumTypeOrNull)
                          .Where(type => type is not null)
                          .Collect();

      context.RegisterSourceOutput(enumTypes, GenerateCode);
   }

				
			

The method GenerateCode iterates over collected types, generates C# code and passes the code to the compiler via context.AddSource. The first parameter of the method AddSource is virtually the file name and must be unique per Source Generator. The generated code is kept in memory and is not written to the file system by default.

				
					 private static void GenerateCode(
        SourceProductionContext context,
        ImmutableArray<ITypeSymbol> enumerations)
   {
      if (enumerations.IsDefaultOrEmpty)
         return;

      foreach (var type in enumerations)
      {
         var code = GenerateCode(type);
         var typeNamespace = type.ContainingNamespace.IsGlobalNamespace 
                ? null
                : $"{type.ContainingNamespace}.";

         context.AddSource($"{typeNamespace}{type.Name}.g.cs", code);
      }
   }
				
			

For the generation of the desired output, we need three things: the namespace, the type name, and the names of the items. One thing to watch out are the types without namespaces. These types end up in the “global namespace” which should not be rendered to output.

				
					 private static string GenerateCode(ITypeSymbol type)
   {
      var ns = type.ContainingNamespace.IsGlobalNamespace 
            ? null
            : type.ContainingNamespace.ToString();
      var name = type.Name;
      var items = GetItemNames(type);

      return @$"// <auto-generated />

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(", ", items)} }};
      }}
   }}
{(ns is null ? null : @"}
")}";
   }
				
			

The item names are fetched by Reflection-like API. A valid Smart Enum item must be staticpublic, a field and have the same type as the enumeration itself. The equality comparison of the types (or rather all ISymbol) must be made using SymbolEqualityComparer.

				
					 private static IEnumerable<string> GetItemNames(ITypeSymbol type)
   {
      return type.GetMembers()
                 .Select(m =>
                         {
                            if (!m.IsStatic ||
                                m.DeclaredAccessibility != Accessibility.Public ||
                                m is not IFieldSymbol field)
                               return null;

                            return SymbolEqualityComparer.Default.Equals(field.Type, type)
                                      ? field.Name
                                      : null;
                         })
                 .Where(field => field is not null);
   }
				
			

Source Generator in Action

After finishing the DemoSourceGenerator and rebuilding the solution, the ProductCategory should have a new static property Items. Adjust the Program.cs in DemoConsoleApplication to print out the names of all items.

				
					using DemoConsoleApplication;

foreach (var item in ProductCategory.Items)
{
   Console.WriteLine(item.Name);
}
				
			

The output should be:

				
					Fruits
Dairy
				
			

NuGet Packaging

In the last step, we want to create NuGet packages for the DemoLibrary and DemoSourceGenerator. One condition, the package DemoLibrary should depend on the DemoSourceGenerator. That way, it should be impossible to use the EnumGenerationAttribute without having the corresponding Source Generator.

By the way, you can set IsPackable to false in DemoConsoleApplication.csproj to prevent creation of a NuGet package for DemoConsoleApplication.

The DemoConsoleApplication.csproj should look like:

				
					<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <LangVersion>10.0</LangVersion>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <IsPackable>false</IsPackable>
    </PropertyGroup>

    <ItemGroup>
        <ProjectReference Include="..\DemoLibrary\DemoLibrary.csproj" />
        <ProjectReference Include="..\DemoSourceGenerator\DemoSourceGenerator.csproj"
                          ReferenceOutputAssembly="false"
                          OutputItemType="Analyzer" />
    </ItemGroup>

</Project>
				
			

One of the required changes should be done in DemoSourceGenerator.csproj. The output, i.e. the DLL, must be moved to analyzers/dotnet/cs because the Source Generators and Analyzers work that way. We can go even further and exclude the default build output from the package by setting IncludeBuildOutput to false.

				
					<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>10.0</LangVersion>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <IncludeBuildOutput>false</IncludeBuildOutput>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces"
                          Version="4.0.1"
                          PrivateAssets="all" />

        <None Include="$(OutputPath)\$(AssemblyName).dll"
              Pack="true"
              PackagePath="analyzers/dotnet/cs"
              Visible="false" />
    </ItemGroup>

</Project>
				
			

To make the DemoLibrary depend on DemoSourceGenerator, we have (at least) two options. We can create a .nuspec file which will contain the whole NuGet package configuration, or add a project reference with PrivateAssets="contentfiles;build". Using PrivateAssets no DLLs and no content files of the Source Generator will end up in the bin folder of the actual application. Furthermore, we should set SetTargetFramework to TargetFramework=netstandard2.0 because NuGet cannot infer the target framework automatically due to the exclusion of the default output of the DemoSourceGenerator (see above IncludeBuildOutput).

The DemoLibrary.csproj should look like the following if using the 2nd option:

				
					<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>10.0</LangVersion>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
    </PropertyGroup>

    <ItemGroup>
        <ProjectReference Include="..\DemoSourceGenerator\DemoSourceGenerator.csproj"
                          PrivateAssets="contentfiles;build"
                          SetTargetFramework="TargetFramework=netstandard2.0" />
    </ItemGroup>

</Project>
				
			

It is time to create a few NuGet packages. For that, go to the folder with the solution file and execute the following command on the console dotnet pack --configuration Release.

Summary

Although writing a Source Generator takes a considerable amount of time, more than enough use cases are impossible to implement otherwise. The capabilities provided by a Source Generator are immense, and I expect more to come shortly.

Being done with the Source Generator itself, we are still not finished with the “code sharing of the future”. In this article, we have been working with several assumptions, which means that the developers must know the internals to successfully use this library, which will not happen besides the author.

The solution for this problem are the Roslyn Analyzers, which we’ll discuss in the following article of this series.

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