Hola 👋, In the enchanting realm of generators, there was a distinguished clan known as the Source Generators. Source generators enable developers to analyse the syntax tree of a C# source code by hooking into the compilation process of the targeted C# project and generating some code, based on some defined logic.
I will focus on the newly improved and performant source generator in C# called the Incremental generator. What distinguishes it from the original generator is its support for caching in its execution pipelines. So when the generator sees a node that it has handled before in its pipeline, instead of re-running the same logic, it just returns the same output for that node or input. However, considerations have to be made to make the generator cache-friendly in some cases.
In this article, I will explain how I built a source generator that analyses my code base for classes marked with a custom attribute and a property with a Guid type named Id. Upon these classes, it generates extra classes that can perform CRUD operations on the discovered classes or types. The gif below demonstrates this.
In the gif below, a CRUD(Create, Read, Update and Delete) class called PersonService is generated for the Person’s class.
Creating The Generator
So I created, a class library project targeting the netstandard2.0 platform, and added the NuGet below:
dotnet add package Microsoft.CodeAnalysis.CSharp --version 4.9.2
Next, I created a class implementing the IIncrementalGenerator interface with a Generator attribute, enabling the class to be seen as a generator.
namespace CRUD_Generator
{
[Generator]
public class MyGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
}
}
}
I didn’t want to generate the helper CRUD classes for just about any class in my source code. I wanted it to be an opt-in feature. To make this happen, I followed the usual pattern of marking the classes that required analysis or generation with an attribute.
Next, I used the RegisterPostInitializationOutput feature to register a call back that adds the attribute used for marking classes to the source code immediately after initialization of the source generator.
namespace CRUD_Generator
{
[Generator]
public class MyGenerator : IIncrementalGenerator
{
public const string GenerateCRUDAttribute = """
namespace CRUD_Generator
{
[AttributeUsage(AttributeTargets.Class)]
public class GenerateCRUDAttribute : Attribute
{
}
}
""";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(ctx =>
{
ctx.AddSource("GenerateCRUDAttribute.g.cs", GenerateCRUDAttribute);
});
}
}
}
I had to set the LangVersion to “latest” in my “csproj” file which enabled using the raw string literal feature in a project targeting netstandard2.0.
Next, I used the ForAttributeWithMetadataName method of the SyntaxProvider to filter out classes marked with the GenerateCRUDAttribute.
var classesMarkedWithTheGeneratorAttribute = context.SyntaxProvider
.ForAttributeWithMetadataName(
"CRUD_Generator.GenerateCRUDAttribute",
(node, _) => node is ClassDeclarationSyntax,
(ctx, _) => (ClassDeclarationSyntax)ctx.TargetNode
);
Knowing how to filter and retrieve information from your source code requires some knowledge of the Syntax Factory API and the Semantic Model API which you can learn here and here
Generating The CRUD Classes For The Selected Types
To generate the classes for the selected types, I used the RegisterSourceOutput method on the context object, which accepts two parameters: An IncrementalValuesProvider<TSource> type (acts as the data source) and a callback that accepts two parameters: A SourceProductionContext which contains methods which can be used to add my generated code to the compilation and a TSource which gives access to the values in the IncrementalValuesProvider<TSource>.
I had to combine my classesMarkedWithTheGeneratorAttribute with the compilation provider, this creates a tuple in the callback having the ClassDeclarationSyntax on the left and the Compilation on the right.
Inside the callback, I used the semantic model API to retrieve the name of the class but that also can be retrieved using the ClassDeclarationSyntax.
I utilized the DiagnosticDescriptor to incorporate an error description for classes that possess the GenerateCRUD attribute but lack an Id property of the Guid type
Next, I composed my generated CRUD code for the class using raw string literals.
Finally, I added the code to the compilation. The snippet contains the RegisterSourceOutput code
context.RegisterSourceOutput(classesMarkedWithTheGeneratorAttribute.Combine(context.CompilationProvider), (ctx, classesAndCompilation) =>
{
var @class = classesAndCompilation.Item1;
var compilation = classesAndCompilation.Item2;
var model = compilation.GetSemanticModel(@class.SyntaxTree);
var classSymbol = model.GetDeclaredSymbol(@class);
var className = classSymbol!.Name;
// or className = @class.Identifier.Text if you don't want to use the semantic model API
// check if class has a property named "Id" of type Guid
var check = @class.Members.OfType<PropertyDeclarationSyntax>()
.Any(p => p.Identifier.Text == "Id" && model.GetTypeInfo(p.Type)
.Type?.Name == "Guid" && p.Modifiers.Any(m => m.Text == "public"));
if (!check)
{
var descriptor = new DiagnosticDescriptor("CRUD01", "GUID type with the property, Id not found", "GUID type with the property, Id not found", "CRUD", DiagnosticSeverity.Error, true);
var diagnostic = Diagnostic.Create(descriptor, @class.GetLocation());
ctx.ReportDiagnostic(diagnostic);
return;
}
var generatedCode = $$"""
namespace CRUD_Generator
{
public class {{className}}Service {
public Guid Id { get; set;}
private static List<{{className}}> _data = new List<{{className}}>();
public {{className}}? Get{{className}}(Guid id) => _data.FirstOrDefault(d => d.Id == id);
public List<{{className}}> GetAll() => _data;
public void Add({{className}} {{className.ToLower()}}) => _data.Add({{className.ToLower()}});
public void Update({{className}} {{className.ToLower()}}) => _data[_data.FindIndex(d => d.Id == {{className.ToLower()}}.Id)] = {{className.ToLower()}};
public void Delete(Guid id) => _data.Remove(_data.FirstOrDefault(d => d.Id == id));
}
}
""";
ctx.AddSource($"{className}Service.g.cs", generatedCode);
});
Connecting The Generator To the Target Project
For the target project to utilize the source generator feature, I had to connect the generator to it in the csproj of the target project.
<ItemGroup>
<ProjectReference Include="..\CRUD-Generator\CRUD-Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
The OutputItem=Analyzer setting enables the target project to view the referenced project as an analyzer.
The ReferenceOutputAssembly=false setting ensures that the target project doesn’t reference the DLL of the source generator during compilation which can be checked in the bin/debug/{dotnetversion} file.
Debugging The Generator
I did have issues debugging my source generators. When I clicked on the debug button of my target project in Visual Studio, the breakpoints added to the source generator did not work 🥲🥲.
In my bid to find a solution, I finally came across a solution that involved setting up a test framework using a library to load the generators. This worked for me with no issues. But then it wasn’t a straightforward and seamless process. 🥲
Finally, I stumbled across a solution on the net that made my day. The gif below demonstrates the steps I followed to replicate.
And that is how easy it is to debug source generators.
Now, when the generator identifies a class that has the GenerateCRUD attribute and includes an Id property of the Guid type, it generates a corresponding CRUD class for it.
Packaging the project as a Nuget Packet
To package the generator as a Nuget package, I made a few edit to the csproj file of the source generator
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<RootNamespace>CRUD_Generator</RootNamespace>
<LangVersion>Latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<Nullable>enable</Nullable>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<Description>This generator creates generates the CRUD version of a type</Description>
<PackageOutputPath>my-nuget</PackageOutputPath>
<IncludeBuiltOutput>false</IncludeBuiltOutput>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll"
Pack="true"
PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>
In the configuration above, I had to package the DLL in the “analyzers/dotnet/cs” path for the target or consuming project to detect the Nuget as a generator. When dealing with third-party packages required by the generator, the configuration is different. You can learn about that here.
Finally, after building the project I got the NuGet package in the location set in <PackageOutputPath>my-nuget</PackageOutputPath> configuration.
Thanks for reading through. You can check out the code used in this article on my GitHub via this link.
To find out more about Incremental Generators in C#, check this