A code that writes a code for you ?
By Mirek on (tags: .net, Source Generators, categories: None)There was an incredible feature introduced in .NET 5 and now it’s even improved - source generators. Let’s have a quick glance on how we can harness its potential.
Every time I need to write a boiler plate or repetitive code I always try to find a way to optimise that. For some cases it’s sufficient to use code snippets, for others it sometimes makes sense to use Reflection. None of these solutions are ideal though.
Now we have new option, which are Incremental Source Generators in .NET.
Let’s briefly see it in action. Create new console application project and define a simple PersonModel class in it
public partial class PersonModel
{
public string Name { get; set; }
public int Age { get; set; }
public DateTime BirthDate { get; set; }
}
Now we want to implement a custom GetHashCode method that calculates the hash code based of all class properties. The method would looks like this:
public override int GetHashCode()
{
return HashCode.Combine(Name, Age, BirthDate);
}
We want that every class that name ends with ‘Model’ would have that GetHashCode method automatically generated.
Lat’s add new class library project to the solution, call it CustomGenerator and change it framework target to netstandard2.0 (for some reason that is still required in .NET 6). Then add two nuget packages to the library:
Microsoft.CodeAnalysis.AnalyzersMicrosoft.CodeAnalysis.CSharp
Now in our main console project project file add reference to the source generator project, but modify it for special output type. This has to be done manually in project file:
<ItemGroup>
<ProjectReference Include="..\CustomGenerator\CustomGenerator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
Now go back to generator project and add new class called HashCodeMethodGenerator that is declared as follows:
[Generator]
public class HashCodeMethodGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
...
}
}
Now the most tricky part. We need to implement the actual generation logic. While the documentation is rather difficult to read, there are few good blog posts to start with, like this one or this one. Btw, the official documentation for source generators is reffering to V1 version of source generators, which is less performant, and with many source code files in the project, makes the IDE less reponsive or even hanging.
The incremental source generator works in a pipeline fashion. First we register a predicate that select out nodes we are interested in. Think of it as a filter that is fed with all the source nodes you project has and returns only those that are applicable for the generator. Then we define a transformation predicate. Note that here we are working on a SyntaxTree from Microsoft.CodeAnalysis namespace. We have a object representation of the whole project source code, which is convinient, as we dont need to parse string source code. This was already done by the language parser and validator and we get ready to use tree of source code representation.
Let’s see the Initialize method implementation that registers our source generator pipeline for GetHashCode method
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var classDeclarations = context.SyntaxProvider
.CreateSyntaxProvider(
(s, _) => s is ClassDeclarationSyntax cds && cds.Identifier.Text.EndsWith("Model"),
(c, _) => c.Node as ClassDeclarationSyntax)
.Where(x => x != null)
.Collect();
context.RegisterSourceOutput(classDeclarations, GenerateSource);
}
First in a predicate delegate we check that syntax node is actually a class node and we check if the class name ends with ‘Model’. Here we could, for example, check the existance of specific attribute the class is decorated, or any other indication we want to filter by. There are many possibilities here.
Then we have a transform delegate, which in this case simply casts the node we found to ClassDeclarationSyntax.
At this moment the source generator logic performs a caching to limit the unnecessary generation cycles. Then we register the source output method, called GenerateSource in this case, which is called with all found class declarations.
private void GenerateSource(SourceProductionContext context, ImmutableArray<ClassDeclarationSyntax> Classes)
{
foreach (var foundClass in Classes)
{
var className = foundClass.Identifier.Text;
var properties = foundClass.Members.Select(m => m as PropertyDeclarationSyntax)
.Where(x => x != null)
.Select(x => x.Identifier.Text)
.ToList();
var classNsp = foundClass.Ancestors()
.FirstOrDefault(x => x is NamespaceDeclarationSyntax) as NamespaceDeclarationSyntax;
var nmspName = classNsp.Name.ToString();
var newClass = $@"namespace {nmspName}
{{
public partial class {className}
{{
public override int GetHashCode()
{{
return HashCode.Combine({string.Join(", ", properties)});
}}
}}
}}";
context.AddSource($"{className}.g.cs", newClass);
}
}
The source generation is quite straight forward. For each class declaration, that matches our predicate, we generate new partial class with the same name. Then we collect all properties names and generate the GetHashCode method with use of HashCode.Combine. Keep in mind this is not the most efficient implementation, but rather a simplified to show the idea of source generators. For instance a StringBuilder would be a better choice to construct the class source code.
Finally we add our new generated source to the context. Note that class must be marked partial (both original one and generated one) for it to work.
Now after rebuilding our solution we should see the generated source file in Visual Studio.
namespace ConsoleApp
{
public partial class PersonModel
{
public override int GetHashCode()
{
return HashCode.Combine(Name, Age, BirthDate);
}
}
}
Sample solution is available to download below.
Cheers
Kirill Prokopenko
10/26/2022 9:11 AM
Great example, but generated files don't get refreshed until you restart visual studio :(