Article series
- Code sharing of the future ⬅
- Better experience through Roslyn Analyzers and Code Fixes
- Testing Source Generators, Roslyn Analyzers and Code Fixes
- Increasing Performance through Harnessing of the Memoization
- Adapt Code Generation Based on Project Dependencies
- Using 3rd-Party Libraries
- Using Additional Files
- High-Level API – ForAttributeWithMetadataName
- Reduction of resource consumption in IDE
- Configuration
- Logging
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:
netstandard2.0
10.0
enable
enable
The (empty) project is targeting netstandard2.0
and is referencing Microsoft.CodeAnalysis.CSharp.Workspaces
. The C# version 10.0
, Nullable = 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:
Exe
net6.0
10.0
enable
enable
The differences mentioned above are two new attributes, which have to be added manually:
ReferenceOutputAssembly="false"
: we don’t need theDemoSourceGenerator.dll
in the folderbin
of theDemoConsoleApplication
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.
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:
//
using System.Collections.Generic;
namespace DemoConsoleApplication
{
partial class ProductCategory
{
private static IReadOnlyList _items;
public static IReadOnlyList Items => _items ??= GetItems();
private static IReadOnlyList 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.
netstandard2.0
10.0
enable
enable
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 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 @$"//
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 static
, public
, 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 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:
Exe
net6.0
10.0
enable
enable
false
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
.
netstandard2.0
10.0
enable
enable
false
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:
netstandard2.0
10.0
enable
enable
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.