In this Article

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 C# code on-the-fly during development.

Article series

  1. Code sharing of the future
  2. Better experience through Roslyn Analyzers and Code Fixes
  3. Testing Source Generators, Roslyn Analyzers and Code Fixes (coming soon)

Code Sharing Today

The most common approach for sharing code/functionality is providing base/helper classes or components for reuse in other projects. Their interfaces and behaviors are relatively static, i. e. the shared code cannot react to the needs of developers like adding/removing a feature or renaming a property without compilation or triggering some external tools. To make it more dynamic, we 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 Source Generators but Roslyn Analyzers, and even the Roslyn Code Fixes as icing on the cake. With Analyzers, we can guide 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.

One of the use cases for an Analyzer and a Code Fix is to extend a custom type with additional members like methods or properties. For this purpose, the type must be partial. If it is not, then the Source Generator should not emit C# code because we would end up with two different classes having the same name. Leaving it that way may either confuse the developer or introduce a bug because of missing behavior. A better approach is to create an Analyzer that emits an error if the type is not partial. In this case, the developer can type in the keyword partial manually, or create a Code Fix which does the same but without typing.

Creation of a New Source Generator

In this article, we want to create a simple 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 can be made later so that it can be packaged as a Nuget package.

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>9.0</LangVersion>
    </PropertyGroup>

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

</Project>

The (empty) project is targeting netstandard2.0 and referencing Microsoft.CodeAnalysis.CSharp.Workspaces. The C# version is set to 9.0 for convenience reasons; besides that, the version doesn't matter.

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

using Microsoft.CodeAnalysis;

namespace DemoSourceGenerator
{
   [Generator]
   public class DemoSourceGenerator : ISourceGenerator
   {
      public void Initialize(GeneratorInitializationContext context)
      {
      }

      public void Execute(GeneratorExecutionContext context)
      {
      }
   }
}

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

  • the interface ISourceGenerator 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>net5.0</TargetFramework>
   </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 and 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 : ISourceGenerator
{
   public void Initialize(GeneratorInitializationContext 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 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 enumerations 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: an 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 to see how the source 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 (if we had any).

Create another library project DemoLibrary, and add a new class EnumGenerationAttribute.

The DemoLibrary.csproj targets netstandard2.0, but unlike with DemoSourceGenerator.csproj, the target framework doesn't matter much.

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

   <PropertyGroup>
      <TargetFramework>netstandard2.0</TargetFramework>
   </PropertyGroup>

</Project>

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

using System;

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

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

using System.Collections.Generic;
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.

Collecting Candidates

We start with the method Initialize, which allows us to collect all pieces we are interested in. In our case, we are interested in classes with the EnumGenerationAttribute. To collect the corresponding classes, we have to implement a new class DemoSyntaxReceiver, which implements the interface ISyntaxReceiver. The ISyntaxReceiver has one method which provides a SyntaxNode. 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.

There are multiple ways to collect enumerations or candidates because we have no type information yet. That means if we find an EnumGenerationAttribute, it could be ours, but there is no guarantee because we don't know the namespace. One option is to look out for the attribute and then fetch the class the attribute is attached to. The other option is to look out for classes and check whether it has the right attribute attached to it.

The stage Initialize is optional. Alternatively, we can search for enumerations in the method DemoSourceGenerator.Execute but collecting candidates in Initialize is easier to implement.

In this demo, we take the first option. Create a new class DemoSyntaxReceiver.cs in the project DemoSourceGenerator with the following content.

using System.Collections.Generic;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace DemoSourceGenerator
{
   public class DemoSyntaxReceiver : ISyntaxReceiver
   {
      public List<ClassDeclarationSyntax> Candidates { get; } = new();

      public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
      {
         if (syntaxNode is not AttributeSyntax attribute)
            return;

         var name = ExtractName(attribute.Name);

         if (name != "EnumGeneration" && name != "EnumGenerationAttribute")
            return;

         // "attribute.Parent" is "AttributeListSyntax"
         // "attribute.Parent.Parent" is a C# fragment the attribute is applied to
         if (attribute.Parent?.Parent is ClassDeclarationSyntax classDeclaration)
            Candidates.Add(classDeclaration);
      }

      private static string ExtractName(TypeSyntax type)
      {
         while (type != null)
         {
            switch (type)
            {
               case IdentifierNameSyntax ins:
                  return ins.Identifier.Text;

               case QualifiedNameSyntax qns:
                  type = qns.Right;
                  break;

               default:
                  return null;
            }
         }

         return null;
      }
   }
}

The method OnVisitSyntaxNode is looking out for nodes of type AttributeSyntax only. The name of the attribute must be either EnumGenerationAttribute or its short form EnumGeneration but before the check, we extract the name of the attribute. The extraction of the name is required in case the attribute is specified with its namespace, like [DemoLibrary.EnumGeneration]. In this case, we throw away the namespace. Having the attribute, we fetch the corresponding class, which will be our enumeration candidate.

The syntax receiver has to be registered via method RegisterForSyntaxNotifications.

using System;
using System.Collections.Generic;
using Microsoft.CodeAnalysis;

namespace DemoSourceGenerator
{
   [Generator]
   public class DemoSourceGenerator : ISourceGenerator
   {
      public void Initialize(GeneratorInitializationContext context)
      {
         context.RegisterForSyntaxNotifications(() => new DemoSyntaxReceiver());
      }

      ...

C# Code Generation

After gathering the candidates, we can start generating C# code. The method Execute fetches the DemoSyntaxReceiver and iterates over the candidates. On every iteration, the candidates are checked whether they are enumerations or not. In this stage, we have access to the type information thanks to the SemanticModel, which means we can check for the DemoLibrary.EnumGenerationAttribute. If the checks are successful, then the C# code is generated by the method GenerateCode and added to the context via context.AddSource. The file names provided to AddSource must be unique per Source Generator; besides that, the name can be anything.

[Generator]
public class DemoSourceGenerator : ISourceGenerator
{
   public void Initialize(GeneratorInitializationContext context)
   {
      context.RegisterForSyntaxNotifications(() => new DemoSyntaxReceiver());
   }

   public void Execute(GeneratorExecutionContext context)
   {
      var receiver = (DemoSyntaxReceiver)context.SyntaxReceiver;

      foreach (var classDeclaration in receiver.Candidates)
      {
         var model = context.Compilation.GetSemanticModel(classDeclaration.SyntaxTree, true);
         var type = model.GetDeclaredSymbol(classDeclaration) as ITypeSymbol;

         if (type is null || !IsEnumeration(type))
            continue;

         var code = GenerateCode(type);
         context.AddSource($"{type.Name}_Generated.cs", code);
      }
   }

   public static bool IsEnumeration(ITypeSymbol type)
   {
      return type.GetAttributes()
                 .Any(a => a.AttributeClass?.ToString() == "DemoLibrary.EnumGenerationAttribute");
   }

   private static string GenerateCode(ITypeSymbol type)
   {
      // TODO
   }
}

IsEnumeration is public because we will need this method when implementing Roslyn Analyzers. An extension method would be the better alternative, but I leave it that way for didactical reasons.

For the generation of the desired output, we need three things: the namespace, type name, and the names of the items. The first two are easy to get, but we have to use the Reflection-like API for fetching the items.

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

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

using System.Collections.Generic;

{(String.IsNullOrWhiteSpace(ns) ? 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)} }};
      }}
   }}
}}
";
   }

   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 System;

namespace DemoConsoleApplication
{
   class Program
   {
      static void Main(string[] args)
      {
         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 DemoLibrary and DemoSourceGenerator. One condition, the packageDemoLibraryshould depend on theDemoSourceGenerator. That way, it should be impossible to use theEnumGenerationAttribute` 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>net5.0</TargetFramework>
      <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>9.0</LangVersion>
      <IncludeBuildOutput>false</IncludeBuildOutput>
   </PropertyGroup>

   <ItemGroup>
      <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces"
                        Version="3.9.0"
                        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 file .nuspec which will contain the whole Nuget package configuration, or add a project reference and add 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>
    </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.

Nuget Packages

Summary

Although writing a Source Generator takes a considerable amount of time compared to Reflection, more than enough use cases are impossible to implement with Reflection alone. 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 is using the Roslyn Analyzers, which we'll discuss in the following article of this series.

If you don't want to miss out on more articles, webinars, videos, and more, subscribe to our monthly dev newsletter.

Related Articles

 | Pawel Gerr

Article series Codesharing of the future Better experience through Roslyn Analyzers and Code Fixes ⬅ Testing Source Generators, Roslyn Analyzers and Code Fixes (coming soon) More background information about the smart-enums and the source code can be found on GitHub: Enum like…

Read article
 | Pawel Gerr

Article series Code sharing of the future Better experience through Roslyn Analyzers and Code Fixes Testing Source Generators, Roslyn Analyzers and Code Fixes More information about the smart enums, the source code, and the documentation of can be found on GitHub: Enum-like…

Read article
 | Pawel Gerr

By switching the to and setting to in the csproj-file we are now benefiting from the introduced with the latest version of C#. By enabling this new feature all type members, input and output parameters are considered to be not-null. If some members or parameters, like can…

Read article