Introducing the Source Generator Toolkit

Source Generators made easier!

Home Blog

Introduction

Ever wanted to try leverage Roslyn Source Generators, but the process seemed too complex? The Source Generator Toolkit aims to make this process easier, but providing two pieces of core functionality:

  • Code Generation: this can be leveraged in conjunction with the Roslyn Source Generator process, or standalone and allows for the easy generation of C# source code using a fluent builder pattern.
  • Code Qualification: this is leverage as part of the Roslyn Source Generator process, and make the process of determining if a syntax node qualifies for source generation easier and more stream-lined.

Reason for the library

The main aim of the code generation functionality of the library is to remove as much of the hard-coded C# strings required when creating a source generator as possible. Some hard-coded strings of code will still be required for specific logic, however the main scaffolding of the classes, methods etc can be done through the library using a fluent builder pattern. This pattern makes it easy to build up the source code logically, with each component building on the previous one.

While the initial idea was for the Source Generator Toolkit to be used with Roslyn Source Generators, the code generation functionality can be leveraged outside of this process to just generate a string representation of C# code.

When creating a Roslyn Source Generators in most cases, before source code is generated, qualification checks need to be performed to determine if the source code should be generated, and to determine the values to be used in the generated code. Here the aim of the Source Generator Toolkit was to make working the the syntax nodes and syntax tree as easy as possible, by providing a variety of extension methods for qualification checks performed on syntax nodes. The qualification check functionality is done, again, through a fluent builder which allows for the qualification check to logically be built up - easily to maintain and easier to understand.


Generating Code - outside of the Roslyn Source Generator process

First we'll look at how to generate c# source code, outside of the Roslyn Source Generator process.

The static SourceGenerator class is the starting point for building up the source code. No actual true c# code is generated here - just a formatted string representation of c# code:

var strCode = SourceGenerator.GenerateSource(gen =>
{
    gen.WithFile("file1", file =>
    {
        file.WithNamespace("SampleNamespace", ns =>
        {
            ns.WithClass("SampleClass", cls => { });
        });
    });
});

The string output of the above being (the value of strCode):

namespace SampleNamespace
{
    [System.CodeDom.Compiler.GeneratedCode("SourceGeneratorToolkit", "0.0.0.1")]
    class SampleClass
    {
    }
}

As you can see from this simple example, defining the code is easy:

  • start with a "file" (in this case, it is an "in-memory" file which will hold the defined c# code)
  • the file contains a single namespace, SampleNamespace
  • the namespace contains a single class, SampleClass

Each component is defined with its required properties (such as name in the above examples), and then an optional builder Action to define its child components.


Generating Code - Roslyn Source Generator process (without ISyntaxReceiver)

Next, we look at leveraging the Source Generator Toolkit as part of the Roslyn Source Generator process - but when the ISyntaxReceiver is not used. This use case is for the scenarios when the generated source code must always be output and is not reliant on a qualification check being done.

When used in conjunction with a Source Generator, the GenerateSource extension method on the GeneratorExecutionContext class can be leveraged.

The below example shows how to generate source code without any information from a SyntaxReceiver - see further down on how the Source Generator Toolkit can be used to generate code in conjunction with a ISyntaxReceiver implementation.

// a ISourceGenerator implementation
public class SampleGenerator : ISourceGenerator
{
    public void Execute(GeneratorExecutionContext context)
    {
        context.GenerateSource("file1", fileBuilder =>
        {
            fileBuilder.WithNamespace("SampleNamespace", nsBuilder =>
            {
                ns.WithClass("SampleClass", cls => { });
            });
        });
    }

    public void Initialize(GeneratorInitializationContext context)
    {
        // no ISyntaxReceiver implementation registered here
    }
}

In the case of a Source Generator, an actual file named file1.cs will be output as part of the generation process.

The output content of the file will be the same as in the previous example:

namespace SampleNamespace
{
    [System.CodeDom.Compiler.GeneratedCode("SourceGeneratorToolkit", "0.0.0.1")]
    class SampleClass
    {
    }
}

The fluent builder pattern is leveraged to build up the source code in exactly the same manner as in the previous example above.


Generating Code - Configuration

There is optional configuration which can be specified when generating the code using either of the above two methods (when calling the GenerateSource method). If no configuration is specified, the default configuration is used.

Configuration Name Description Default Value
OutputGeneratedCodeAttribute Flag to indicate if the System.CodeDom.Compiler.GeneratedCode attribute should be output with generated code. This attribute is used as an indicator to various tools that the code was auto generated true
OutputDebuggerStepThroughAttribute Flag to indicate if the System.Diagnostics.DebuggerStepThrough attribute should be output with generated code. When set to true, this attribute allows stepping into the generated code when debugging false

Code Qualification - Roslyn Source Generator process (with ISyntaxReceiver implementation)

When using the .NET Roslyn Source Generator process, the actual generation of the source is only one step of the process - the other step is determining if the source should be generated in the first place. This qualification check is done in the OnVisitSyntaxNode method of the ISyntaxReceiver implementation.

The OnVisitSyntaxNode method takes a SyntaxNode as an argument (this is part of the normal Roslyn Source Generator process) - the Source Generator Toolkit provides an extension method (NodeQualifiesWhen) which accepts a qualification builder which is used to determine if the SyntaxNode qualifies to have source code generated.

The fluent builder pattern is again used to build up the qualification check for for the syntax:

class SampleClassSyntaxReceiver : ISyntaxReceiver
{
    public List<SyntaxReceiverResult> Results { get; set; } = new List<SyntaxReceiverResult>();

    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        syntaxNode.NodeQualifiesWhen(Results, node =>
        {
            node.IsClass(c => c
                .WithName("SampleClass")
                .IsNotStatic()
                .IsPublic()
                .Implements("ISerializable")
            );
        });
    }
}

In the above example, if the qualification checks determines the node is:

  • a class named SampleClass
  • which is public
  • and not static
  • and also implements ISerializable

then the specific SyntaxNode qualifies, and the Results list will be populated and passed to the Execute method of the generator.

A most complex, but less practical example:

syntaxNode.NodeQualifiesWhen(Results, node =>
{
    node.IsClass(c => c
        .WithName("SampleClass")
        .IsNotStatic()
        .IsNotPrivateProtected()
        .IsPublic()
        .Implements("ISerializable")
        // the class must have the Obsolete attribute 
        .WithAttribute(a =>
        {
            a.WithName("Obsolete");
        })
        .WithMethod(m =>
        {
            // the class must have a method called "SampleMethod"
            m.WithName("SampleMethod")
            // which is async
            .IsAsync()
            // with the Obsolete attribute with a parameter in position 1 supplied
            .WithAttribute(a =>
            {
                a.WithName("Obsolete")
                .WithArgument(arg =>
                {
                    arg.WithPosition(1);
                });
            })
            // method must have a return type of Task
            .WithReturnType(typeof(Task));
        })
    );
});

Code Generation - Roslyn Source Generator process (with ISyntaxReceiver implementation)

When generating code based on the output of the qualification process (OnVisitSyntaxNode method in the ISyntaxReceiver implementation, shown above), the Results list is populated with the qualifying SyntaxNode(s), and passed to the Execute method of the ISourceGenerator implementation.

Using the same ISyntaxReceiver implementation as above:

class SampleClassSyntaxReceiver : ISyntaxReceiver
{
    public List<SyntaxReceiverResult> Results { get; set; } = new List<SyntaxReceiverResult>();

    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        syntaxNode.NodeQualifiesWhen(Results, node =>
        {
            node.IsClass(c => c
                .WithName("SampleClass")
                .IsNotStatic()
                .IsPublic()
                .Implements("ISerializable")
            );
        });
    }
}

For each qualifying node, the Results property is populated with the qualifying SyntaxNode in question.

Below is a sample of a ISourceGenerator which used the Results output from the OnVisitSyntaxNode method to generate source code:

[Generator]
public class PartialMethodGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
        // Register our custom syntax receiver
        context.RegisterForSyntaxNotifications(() => new PartialClassSyntaxReceiver());
    }

    public void Execute(GeneratorExecutionContext context)
    {
        if (context.SyntaxReceiver == null)
        {
            return;
        }

        PartialClassSyntaxReceiver syntaxReceiver = (PartialClassSyntaxReceiver)context.SyntaxReceiver;

        if (syntaxReceiver != null && syntaxReceiver.Results != null && syntaxReceiver.Results.Any())
        {
            foreach (SyntaxReceiverResult result in syntaxReceiver.Results)
            {
                // based on the qualification process
                // we know the qualifying node will be a class
                ClassDeclarationSyntax cls = result.Node.AsClass();

                context.GenerateSource($"{cls.GetNamespace()}_file", fileBuilder =>
                {
                    fileBuilder.WithNamespace($"{cls.GetNamespace()}", nsBuilder =>
                    {
                        nsBuilder.WithClass($"{cls.GetName()}_generated", clsBuilder =>
                        {
                            clsBuilder.AsPublic();

                            clsBuilder.WithMethod("Hello", "void", mthBuilder =>
                            {
                                mthBuilder.AsPublic()
                                .WithBody(@"Console.WriteLine($""Generator says: Hello"");");
                            });
                        });
                    });
                });
            }
        }
    }
}
  • The Initialize method is used to register the custom ISyntaxReceiver implementation containing the qualification rules - this is part of the normal Roslyn source generation processes (not specific to the Source Generator Toolkit)
  • The GeneratorExecutionContext parameter passed to the Execute method contains a ISyntaxReceiver implementation property - PartialClassSyntaxReceiver in this example, which contains the Results property with the qualifying SyntaxNode(s). A number of checks are performed to ensure the SyntaxReceiver is not null, and that the Results property on it is not null.
  • The code then iterates over each SyntaxReceiverResult in the Results property. In other words, iterating through each qualifying node
  • The AsClass extension method (part of the Source Generator Toolkit) will convert the generic SyntaxNode to the specific syntax type ('ClassDeclarationSyntax' in this example)
  • The GenerateSource extension method (again, part of the Source Generator Toolkit) then allows for the building up of the required source code as described above. However, now, instead of explicitly supplying the values for the code (the file name, namespace and class name in this example), the provided extension methods are used to extract the values from the qualifying syntax node.
  • In this example, the GetNamespace and GetName extension methods on ClassDeclarationSyntax are used to get the relevent details from the syntax to populate the generated source code

Custom syntax qualifiers

The Source Generator Toolkit allows for custom qualification checks using the WithQualifyingCheck method:

syntaxNode.NodeQualifiesWhen(Results, node =>
{
    node.WithQualifyingCheck(customNode =>
    {
        // completely un-useful check
        return customNode.ChildNodes().Count() == 10;
    });
});

Here instead of checking if the node is a class or attribute for example, the qualification check is to see if the node contains 10 child nodes (a not very useful check)

Future enhancements

The library is a work in progress, with common source generator functionality added initially, but with more to come over time. Some future enhancements include (but not limited to):

  • SyntaxNode AsAttribute extension method
  • Additional extension methods to be used on ClassDeclarationSyntax and AttributeDeclarationSyntax to be leverage when doing code generation in a source generator
  • Ability to determine qualification with generics and generic types
  • Ability to determine qualification based on a code comment

Feel free to log a request or bug if you have a specific requirement and I'll try to implement asap.


Source Generator Toolkit
Debugging a source generator

c# .net sourcegenerator roslyn toolkit sourcegeneratortoolkit