DEV Community

Cover image for Understanding source generators
Serhii Korol
Serhii Korol

Posted on

Understanding source generators

In this article, I want to discuss source generators. The source generator can automate some work without your participation. I'll describe a typical case. You all use DI and always create interfaces. You can automate this job and create interfaces automatically based on your class. The interfaces will be updated when your classes are updated. This work routine prompted me to make this article and create a helpful tool for myself.

Let's take a closer look at how it works in practice.

Generator

The generator represents the implementation of the modern IIncrementalGenerator interface. This generator doesn't require running from your side. It'll do the IDE. The popular IDEs, like Visual Studio or Rider, allow you to work with Roslyn. Exactly, Roslyn does all the work. For this reason, you need to install the appropriate NuGet package:

Microsoft.CodeAnalysis.CSharp.Workspaces
Enter fullscreen mode Exit fullscreen mode

The Roslyn uses the netstandard2.0 framework and is compatible with .NET7 and .NET8. Unfortunately, it doesn't support.NET9. As a rule, the source generators are made in separate projects, and there, you should use netstandard2.0.

The typical source generator is implemented from IIncrementalGenerator. Sure, you can use ISourceGenerator, but it is not recommended because it is deprecated.

[Generator]
public class CustomGenerator : IIncrementalGenerator
{

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {

    }
}
Enter fullscreen mode Exit fullscreen mode

Only one method is implemented. The [Generator] attribute works as a trigger that tells the system that this class is a generator. As I mentioned, you do not need to run this generator; the IDE will do this instead of you. The generator consists of two parts. The first part is creating the SyntaxProvider provider from context. The CreateSyntaxProvider has two functions as arguments. The predicate function determines what you need to transform. This function parses attributes that suit our generator. If we add other attributes, this function will return false, and the second function won't work. In other words, the predicate function parses triggers and conditions by which we need to generate code.

predicate: static (syntaxNode, _) =>
                    syntaxNode is ClassDeclarationSyntax classDecl &&
                    classDecl.AttributeLists
                        .SelectMany(attrList => attrList.Attributes)
                        .Any(attr => attr.Name.ToString().Contains("Contract"))
Enter fullscreen mode Exit fullscreen mode

The second function transforms the triggered class. In our case, we parse class metadata and return the custom model. The function seeks another attribute and gets the input property. The custom namespace needs to be set.

var containingClass = (INamedTypeSymbol)syntaxContext.SemanticModel.GetDeclaredSymbol(syntaxContext.Node)!;
                    string? customNamespace = containingClass.GetAttributes()
                        .FirstOrDefault(attr => attr.AttributeClass?.Name == "Namespace")
                        ?.ConstructorArguments.FirstOrDefault().Value?.ToString();

                    var targetNamespace = customNamespace ?? containingClass.ContainingNamespace?.ToDisplayString(
                        SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)
                    );
Enter fullscreen mode Exit fullscreen mode

Next, we need to parse all methods with signatures, excluding constructors, from the class:

var methodSignatures = containingClass.GetMembers()
                        .OfType<IMethodSymbol>()
                        .Where(method => method.MethodKind != MethodKind.Constructor)
                        .Select(FormatMethodSignature)
                        .ToList();
Enter fullscreen mode Exit fullscreen mode

The second part is forming an interface and interpolating dynamic metadata because we can't hardcode the namespace or an interface name. However, you can make hardcoded metadata in cases where you need static data. Finally, we set the name of the output file and attach the source code.

context.RegisterSourceOutput(pipeline, static (context, model) =>
        {
            if (model == null || !model.Methods.Any()) return;

            var sourceText = SourceText.From($$"""
                                               namespace {{model.Namespace}};
                                               public interface I{{model.ClassName}}
                                               {
                                                   {{string.Join("\n    ", model.Methods)}}
                                               }
                                               """, Encoding.UTF8);

            context.AddSource($"I{model.ClassName}.g.cs", sourceText);
        });
Enter fullscreen mode Exit fullscreen mode

The entire class should look like this:

public class CustomGenerator : IIncrementalGenerator
{

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var pipeline = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (syntaxNode, _) =>
                    syntaxNode is ClassDeclarationSyntax classDecl &&
                    classDecl.AttributeLists
                        .SelectMany(attrList => attrList.Attributes)
                        .Any(attr => attr.Name.ToString().Contains("Contract")),
                transform: (syntaxContext, _) =>
                {
                    var containingClass = (INamedTypeSymbol)syntaxContext.SemanticModel.GetDeclaredSymbol(syntaxContext.Node)!;
                    string? customNamespace = containingClass.GetAttributes()
                        .FirstOrDefault(attr => attr.AttributeClass?.Name == "Namespace")
                        ?.ConstructorArguments.FirstOrDefault().Value?.ToString();

                    var targetNamespace = customNamespace ?? containingClass.ContainingNamespace?.ToDisplayString(
                        SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)
                    );

                    var methodSignatures = containingClass.GetMembers()
                        .OfType<IMethodSymbol>()
                        .Where(method => method.MethodKind != MethodKind.Constructor)
                        .Select(FormatMethodSignature)
                        .ToList();
                    return new InterfaceModel
                    {
                        Namespace = targetNamespace,
                        ClassName = containingClass.Name,
                        Methods = methodSignatures.Any() ? methodSignatures : new List<string>()
                    };
                }
            );

        context.RegisterSourceOutput(pipeline, static (context, model) =>
        {
            if (model == null || !model.Methods.Any()) return;

            var sourceText = SourceText.From($$"""
                                               namespace {{model.Namespace}};
                                               public interface I{{model.ClassName}}
                                               {
                                                   {{string.Join("\n    ", model.Methods)}}
                                               }
                                               """, Encoding.UTF8);

            context.AddSource($"I{model.ClassName}.g.cs", sourceText);
        });
    }

    private string FormatMethodSignature(IMethodSymbol method)
    {
        var returnType = method.ReturnType.ToDisplayString();
        var methodName = method.Name;
        var parameters = string.Join(", ", method.Parameters.Select(param => $"{param.Type} {param.Name}"));
        return $"{returnType} {methodName}({parameters});";
    }
}

public class InterfaceModel
{
    public string Namespace { get; set; }
    public string ClassName { get; set; }
    public IEnumerable<string> Methods { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Testing

Another crucial thing is that you need to test it. I'll show you how to do unit tests. I like the xUnit test framework, and I will demonstrate testing with it. Before we start writing tests, let's create the generator driver and compile the output file.

private static string? GetGeneratedOutput(string sourceCode)
    {
        var namespaceAttributeSource = @"
    using System;
    [AttributeUsage(AttributeTargets.Class, Inherited = false)]
    public class Namespace : Attribute
    {
        public Namespace(string targetNamespace) => TargetNamespace = targetNamespace;
        public string TargetNamespace { get; }
    }
    ";

        var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);
        var attributeSyntaxTree = CSharpSyntaxTree.ParseText(namespaceAttributeSource);

        var references = AppDomain.CurrentDomain.GetAssemblies()
            .Where(assembly => !assembly.IsDynamic)
            .Select(assembly => MetadataReference.CreateFromFile(assembly.Location))
            .Cast<MetadataReference>();

        var compilation = CSharpCompilation.Create("SourceGeneratorTests",
            syntaxTrees: [syntaxTree, attributeSyntaxTree],
            references: references,
            options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

        var generator = new InterfaceGenerator.InterfaceGenerator.CustomGenerator();
        CSharpGeneratorDriver.Create(generator)
            .RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _);

        return outputCompilation.SyntaxTrees.SingleOrDefault(t => t.FilePath.Contains("IUserClass.g.cs"))?.ToString();
    }
Enter fullscreen mode Exit fullscreen mode

The method generates the attribute and parses it. The same it parses source code.

Now, let's write a unit test. I'll use FluentValidation, but you can use the xUnit validator. One crucial thing: The expected code should not contain spaces or tabs.

[Fact]
    public void GenerateInterface_WithoutCustomNamespace()
    {
        var source = @"
        namespace Tooller.Unit;
        [Contract]
        public class UserClass
        {
            public void UserMethod() { }
            public int GetValue(int param) => param * 2;
        }";

        var expected = @"
namespace Tooller.Unit;
public interface IUserClass
{
    void UserMethod();
    int GetValue(int param);
}
        ".Trim();
        var generatedCode = GetGeneratedOutput(source);
        generatedCode.Should().NotBeNull();
        generatedCode.Should().Be(expected);
    }
Enter fullscreen mode Exit fullscreen mode

Using

You can use the source generator in two ways: implement it and use a reference or generate a NuGet package. In both cases, you need to edit a target project. You should add these properties to your .csproj file.

<PropertyGroup>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild
    <IncludeBuildOutput>false</IncludeBuildOutput>
</PropertyGroup>
<ItemGroup>
    <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
Enter fullscreen mode Exit fullscreen mode

In the first case, you should also add a reference to the source generator. In the second case, you need to install the NuGet package.
You can reuse and conveniently use a package. You can use nuget.org or nuget.pkg.github.com or a local source to publish the package.
I created and published this package and described a manual for installing and using it. You can find it by link.

What about using? Just create the class and, on the fly, get the interface:

[Contract]
public class UserService : IUserService
{
    public string GetUserId(Guid id)
    {
        return id.ToString();
    }
}
Enter fullscreen mode Exit fullscreen mode

I hope it was helpful to you and that you learned a little about source generators. Happy coding!

Buy Me A Beer

Top comments (0)