Source generators are a new feature of .NET 5. You may be interested in writing source generators to help remove boilerplate, improve application performance, or generally perform tasks at compile time rather than at runtime.

This post will show how we can retrieve class declarations from a project with our source generator referenced.

General Overview of Source Generators

A source generator allows developers to “write” code during the compilation process. Using the existing project, we can derive additional assets which we can add to our final artifacts. A source generator has two defining characteristics:

  • The Microsoft.CodeAnalysis .Generator attribute decorating the class. This attribute causes a project to consider a class as source generator.
  • The Microsoft.CodeAnalysis.ISourceGenerator interface, which expects implementations for Initialize and Execute.

Let’s look at a simple source generator implementation:

[Generator]
public class HelloWorldGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context) { }

    public void Execute(GeneratorExecutionContext context)
    {
        context.AddSource(
            "hello.world", 
            SourceText.From(@"public class HelloWorld { }", Encoding.UTF8)
        );
    }
}

The source generator writes a HelloWorld class into our final assembly, which we can use. As we can see, the type doesn’t do much, but we can reference it.

To utilize generators in your projects, I recommend reading my previous Jumpstart guide on Source Generators.

Using ISyntaxReceiver To Find Class Declarations

When within the Execute method of our source generator, we can access the context and traverse the syntax tree of our referencing project. The context has a Compilation property that holds all the syntax trees in our current project.

var controllers =
context.Compilation
    .SyntaxTrees
    .SelectMany(syntaxTree => syntaxTree.GetRoot().DescendantNodes())
    .Where(x => x is ClassDeclarationSyntax)
    .Cast<ClassDeclarationSyntax>()
    .Where(c => c.Identifier.ValueText.EndsWith("Controller", StringComparison.OrdinalIgnoreCase))
    .ToImmutableList();

That’s “works” but is clunky and gets difficult to manage quickly. We can instead use the ISyntaxReceiver interface.

public class ControllerFinder : ISyntaxReceiver
{
    public List<ClassDeclarationSyntax> Controllers { get; }
        = new();
    
    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (syntaxNode is ClassDeclarationSyntax controller)
        {
            if (controller.Identifier.ValueText.EndsWith("Controller"))
            {
                Controllers.Add(controller);
            }
        }
    }
}

To register our ControllerFinder syntax receiver, we need to update our source generator’s Initialize method.

public void Initialize(GeneratorInitializationContext context)
{
    context.RegisterForSyntaxNotifications(() => new ControllerFinder());
}

Finally, we can retrieve the class declarations that matched the criteria in our source generator’s Execute method.

public void Execute(GeneratorExecutionContext context)
{
    var controllers = 
        ((ControllerFinder) context.SyntaxReceiver)?.Controllers;
    
    // use controllers to do work...
}

The implementation of ISyntaxReceiver can store any values, including custom classes and types. By the time we reach the Execute method, we could have all the metadata required to generate the additional assets.

Here’s the full implementation of our source generator with ISyntaxReceiver.

[Generator]
public class ControllerGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
        context.RegisterForSyntaxNotifications(() => new ControllerFinder());
    }

    public void Execute(GeneratorExecutionContext context)
    {
        var controllers = 
            ((ControllerFinder) context.SyntaxReceiver)?.Controllers;
        
        // use controllers to do work...
    }

    public class ControllerFinder : ISyntaxReceiver
    {
        public List<ClassDeclarationSyntax> Controllers { get; }
            = new();
        
        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
            if (syntaxNode is ClassDeclarationSyntax controller)
            {
                if (controller.Identifier.ValueText.EndsWith("Controller"))
                {
                    Controllers.Add(controller);
                }
            }
        }
    }
}

SourceGeneratorsKit NuGet Package

While we could write our own ISyntaxReceiver implementations, .NET developer Andrii Kurdiumov has been gracious enough to write some great starter implementations in his SourceGeneratorsKit project. The package includes receivers that can discover classes according to the following:

  • Finding methods that have attributes with the MethodsWithAttributeReceiver.
  • Finding all classes which implement an interface with ClassesWithInterfacesReceiver.
  • Finding all classes which derive from a well-known type with DerivedClassesReceiver.

I recommend looking at this package, SourceGeneratorsKit, as a starting point for your source generator receivers. You might find that these ISyntaxReceiver implementations are enough for your specific use case.

Conclusion

Dealing with syntax trees can be tricky and confusing. Querying for explicit values in the source generator’s Execute method can also add bloat to our logic. Using ISyntaxReceiver can help reduce the noise in our generators, and help us transform SyntaxNode instances into friendly metadata values, ultimately making a source generator’s Execute method better.