Blog
C# Source Generators - How to handle Generic Attributes
This blog post describes how you can detect, handle and use a Generic Attribute (MyAttribute<T>) in a source generator.
๐ Intro
In my previous blog posts, I explained how to write a C# Source Generator from scratch and how to easily create unit tests for this using an NuGet package.
It’s advised to read those articles to get a heads-up for this blogpost.
๐ก Custom Attribute
I did create two projects which use a custom attribute which can be applied on a class to trigger the source generator to generate some C# code for that class.
1๏ธโฃ FluentBuilder
This is a project which uses source generation to create a FluentBuilder for a specified model class.
When you want define a class which needs to act as a builder, you need to create a public and partial builder class and annotate this class with the attribute [AutoGenerateBuilder(typeof(XXX))] where XXX is the type for which you want to generate a FluentBuilder.
Example
using FluentBuilder;
[AutoGenerateBuilder(typeof(UserDto))]
public partial class MyUserDtoBuilder
{
}
Usage
using System;
using FluentBuilder;
namespace Test;
class Program
{
static void Main(string[] args)
{
var user = new MyUserDtoBuilder()
.WithFirstName("Stef")
.WithLastName("Heyenrath")
.Build();
Console.WriteLine($"{user.FirstName} {user.LastName}");
}
}
That attribute AutoGenerateBuilder should have 1 mandatory parameter of type Type.
See the next excerpt how this can look:
internal sealed class AutoGenerateBuilderAttribute : Attribute
{
public Type Type { get; }
public bool HandleBaseClasses { get; }
public AutoGenerateBuilderAttribute(Type type) : this(type, true)
{
}
public AutoGenerateBuilderAttribute(Type type, bool handleBaseClasses)
{
Type = type;
HandleBaseClasses = handleBaseClasses;
}
}
More details about the project and Nuget can be found here.
2๏ธโฃ ProxyInterfaceSourceGenerator
This project uses source generation to generate an interface and a proxy class for existing classes.
This makes it possible to wrap external classes which do not have an interface in a proxy class which makes it easier to Mock and use Dependency Injection.
Example
When you have an existing class which does not implement an interface:
public sealed class Person
{
public string Name { get; set; }
public string HelloWorld(string name)
{
return $"Hello {name} !";
}
}
You can use this project to create an interface and a proxy class. To achieve this you need to create a partial interface and annotate this with the attribute Proxy with the Type which needs to be proxied:
[Proxy(typeof(Person))]
public partial interface IPerson
{
}
Usage
Person realPerson = new Person();
IPerson proxyPerson = new PersonProxy(realPerson);
proxyPerson.Name = "test";
proxyPerson.HelloWorld("stef");
That attribute Proxy looks more or less the same as the previously described attribute.
More details about the project and Nuget can be found here.
๐ Challenge
The above attributes do work fine, however are a bit cumbersome to use.
That’s why I’ll explain in the following chapters how to modify the source generator code to also accept a generic attribute like [AutoGenerateBuilder<MyType>()] where MyType is the generic type for which this attribute should apply.
There are two important changes required:
- Generic Attributes should be supported
- Source Generator should be modified to understand this Generic Attribute
Generic Attributes
From C# 11 and onwards, you can now define generic attributes that accept a type parameter, making them more usable.
Example
Defining a generic attribute in C# 11 is quite similar to how you define a regular generic class.
Here’s a simple example:
public class ValidatorAttribute<T> : Attribute
{
public string RuleName { get; set; }
public ValidatorAttribute(string ruleName)
{
RuleName = ruleName;
}
}
The above example demonstrates a generic attribute called ValidatorAttribute that accepts a type parameter T. You can specify any type while using the attribute, giving you flexibility in scenarios where the type of data you need to validate can change.
Support generic attribute in a source generator
Defining this attribute is easy in C# 11, but using it in a source generator poses two challenges:
- The source generator should detect if C# 11 is used in the project
- The source generator should support this generic attribute and ‘extract’ the Type
โ๏ธ Supporting a generic attribute
Detection
To be able to handle C# 11, the correct version of theMicrosoft.CodeAnalysis.CSharp NuGet is required to be able determine the used LanguageVersion from the project. In this case, the minimum version is 4.11.0.
And to use a project which references this version from Microsoft.CodeAnalysis.CSharp, at least Visual Studio 17.11.5 is required.
Detecting the LanguageVersion can be done by accessing the GeneratorExecutionContext and checking the ParseOptions.
See the next code excerpt:
GeneratorExecutionContext context = . . .;
// Check Language
if (context.ParseOptions is not CSharpParseOptions options)
{
throw new NotSupportedException("Only C# is supported.");
}
// Check if C# 11 is supported
var supportsGenericAttributes = options.LanguageVersion >= LanguageVersion.CSharp11;
Supporting
In order to support this generic attribute and extract the Type which is used in the source generator to execute the required logic for that type, some updated code is needed.
Checking
The current code uses the ClassDeclarationSyntax to check (by name) if the correct attribute is added to a class, and thus will be processed by the source generator.
Code excerpt is like:
ClassDeclarationSyntax classDeclarationSyntax = . . .;
var attributeList = classDeclarationSyntax.AttributeLists
.FirstOrDefault(x => x.Attributes.Any(a => a.Name.ToString() == "AutoGenerateBuilder"));
if (attributeList is null)
{
Console.WriteLine("ClassDeclarationSyntax should have the correct attribute.");
return false;
}
This code has to be extended to also allow an attribute which looks like: [AutoGenerateBuilder<MyType>()].
A simple solution for this is use a regular expression to check this: @"^FluentBuilder\.AutoGenerateBuilder|AutoGenerateBuilder(?:]+)>)?$".
Processing
Once the attribute is detected, the following code can be used to determine the type and use this further down the road when the source generator generates files based on that detected type.
From the AttributeSyntax, get the Name and check if this is a GenericNameSyntax . In case this is true, get the type.
The next code is an excerpt:
AttributeSyntax attributeSyntax = . . .;
NameSyntax nameSyntax = attributeSyntax.Name;
if (nameSyntax is GenericNameSyntax genericRightNameSyntax)
{
var typeSyntax = genericRightNameSyntax.TypeArgumentList.Arguments.First();
// ๐ This can be used in the source generator (e.g. MyNamespace.MyType)
var typeAsString = typeSyntax.ToString();
}
For a the class which implements this logic, see FluentBuilderGenerator/SyntaxReceiver/AttributeArgumentListParser.cs.
๐งช Testing
Unit testing a source generator which uses a Generic Attribute works the same as before.
For a refresh on Unit Testing, see this link.
Note that this NuGet: CSharp.SourceGenerators.Extensions is still required to enable easy unit testing.
Make sure you use the most recent version which has built-in support for adding a generic attribute to a class.
Example
The next example shows a unit-test which displays the usage of the new generic attribute.
[Fact]
public void GenerateFiles_ForFluentBuilder_Should_GenerateCorrectFiles()
{
// Arrange
var path = "./DTO2/MyUserBuilder.cs";
var sourceFile = new SourceFile
{
Path = path,
Text = File.ReadAllText(path),
// ๐ just use this when you want to use a generic attribute in a unit-test
AttributeToAddToClass = "AutoGenerateBuilder<MyUser>"
};
// Act
var result = _sut.Execute(Namespace, [sourceFile]);
// Assert
result.Valid.Should().BeTrue();
result.Files.Should().HaveCount(NumFiles + 1);
result.Files.Last().Text.Should().Be("... generated C# file ...");
}
๐ Conclusion
Expanding my two source-generator projects, FluentBuilder and ProxyInterfaceSourceGenerator, to include support for generic attributes was both an engaging and fun experience.
It did require some additional changes to my source-generators, but when following this blogpost, this should be straightforward and not pose any problems. If you still encounter issues or have questions, see my contact details below or create an issue in GitHub for one of the projects.
๐ Links
๐ Notes
Some content in this blog is created with the help of an AI. I did review and revise the content where needed.
Written by: Stef Heyenrath
Stef started writing software for the Microsoft .NET framework in 2007. Over the years, he has developed into a Microsoft specialist with experience in: backend technologies such as .NET, NETStandard, ASP.NET, Ethereum, Azure, and other cloud providers. In addition he worked with several frontend technologies such as Blazor, React, Angular, Vue.js.
He is the author from WireMock.Net.
Mission: Writing quality and structured software with passion in a scrum team for technically challenging projects.
Want to know more about our experts? Contact us!
