C# Meta programming with Unity

What to automate.

Do you love Factorio? It’s a marvelous game about process automation. My favorite stage in the game is when players transition from manually building factories to replicating them from blueprints, which are automatically assembled by little bots. But why do I mention this? Similarly, in software development, we can automate the tedious task of writing boilerplate code. Specifically, this post explores automating code generation in Unity using Roslyn Source Generators.

Consider you have a class with a field whose value changes need to be tracked:

public class MyClass
{
    private int m_Counter; // Wish we could track its changes
}

In C#, there are several approaches to achieve this, from using BindableField, implementing IObservable interfaces, to crafting an event alongside a property that fires upon value changes. For example, one might implement it as follows:

public class MyClass
{
    private int m_Counter;
    public event System.Action<int> OnCounterChanged;

    public int Counter
    {
        get => m_Counter;
        set
        {
            if (m_Counter != value)
            {
                m_Counter = value;
                OnCounterChanged?.Invoke(m_Counter);
            }
        }
    }
}

This implementation, though straightforward, becomes cumbersome as we add more fields requiring similar monitoring. While the traditional approach might involve creating classes or interfaces to encapsulate this pattern, we propose a different route: let Roslyn generate this boilerplate for us.

Introducing Roslyn Source Generators

Roslyn Source Generators are a powerful feature in .NET that allows C# developers to generate and insert additional source code into their projects at compile time. It’s part of the Roslyn compiler platform, which provides APIs for analyzing and generating C# code.

Source Generators don’t directly modify the source code. Instead, they operate similarly to code analyzers, but rather than issuing warnings or errors, they create new source code that automatically compiles along with the rest of the project. This opens up possibilities for automating many aspects of software development, such as generating code templates, automatically creating APIs based on metadata, and implementing repetitive design patterns without the need for manual copying and pasting of code.

However, integrating Source Generators directly into Unity requires a workaround: we develop the generator as an external .netstandard 2.0 library, compile it into a DLL, import this into Unity, and mark it as a Roslyn Analyzer. While this post won’t cover the implementation details (refer to Unity’s documentation on roslyn analyzers for that), the concept opens doors to automating various development tasks in Unity.

First, we define the criterion by which we will search for fields to subscribe to. Let it be the Subscription attribute. Since our class will be expanded with the help of Roslyn, we must declare it as partial. Other job will be done with Roslyn.(for the sake of simplicity, we’ll omit some nuances and will not analyze namespaces, nested classes, etc.).

public class Subscription: Attribute {}

public partial class MyClass
{
    [Subscription]
    private int m_Counter; 
}

SourceGenerator Implementation – template approach

So, what tools does Roslyn provide us?

  • Syntax Tree: Represents a structured, hierarchical view of the source code. The syntax tree allows Source Generators to analyze code for structures and patterns that need to be processed or supplemented.
  • Symbol Information: Provides the semantic context of code elements, such as classes, methods, properties. This enables understanding not just the structure of the code, but also its semantic meaning, facilitating the creation of accurate and context-dependent code generations.

To work with the syntax tree and generate our own code, we’ll need the SourceGenerator itself. It must implement the ISourceGenerator interface and have the [Generator] attribute.

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

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

    public void Execute(GeneratorExecutionContext context)
    {
    }
}

In the Initialize method, we’ll hook up an ISyntaxReceiver, which will carry out the search for all fields marked with the attribute. Here’s an example of such a receiver:

public class SubscriptionSyntaxReceiver : ISyntaxReceiver
{
    public readonly List<FieldDeclarationSyntax> FieldsWithSubscriptionAttribute = new();

    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (syntaxNode is FieldDeclarationSyntax fieldDeclarationSyntax)
        {
            var attributes = fieldDeclarationSyntax.AttributeLists.SelectMany(a => a.Attributes);
            if (attributes.Any(a => a.Name.ToString() == "Subscription"))
            {
                FieldsWithSubscriptionAttribute.Add(fieldDeclarationSyntax);
            }
        }
    }
}

The SubscriptionSyntaxReceiver class implements the ISyntaxReceiver interface and is called for each node of the syntax tree. During processing, it looks for field declarations with the Subscription attribute and stores them in the FieldsWithSubscriptionAttribute list. The ISyntaxReceiver can be used in situations where we need to find something in the code tree. We could also do this directly in the Execute method of the SimpleSourceGenerator class, where there’s access to the entire tree. For example, like this:

public void Execute(GeneratorExecutionContext context)
{
    foreach (var syntaxTree in context.Compilation.SyntaxTrees)
    {
        foreach (var node in syntaxTree.GetRoot().DescendantNodes())
        {
            //Same nodes can be processed here.
        }
    }
}

However, using the ISyntaxReceiver is more efficient as it allows Roslyn to collect the necessary nodes at the compilation stage, avoiding the need to manually iterate through all the nodes.

Let’s connect the ISyntaxReceiver to the SimpleSourceGenerator.

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

And let’s write our class generator based on a string template of code.

  public void Execute(GeneratorExecutionContext context)
    {
        if (context.SyntaxReceiver is not SubscriptionSyntaxReceiver receiver)
            return;

        foreach (var field in receiver.FieldsWithSubscriptionAttribute)
        {
            var fieldName = field.Declaration.Variables.First().Identifier.Text;
            var fieldType = ((PredefinedTypeSyntax)field.Declaration.Type).Keyword.Text;
            var className = ((ClassDeclarationSyntax)field.Parent).Identifier.Text;
            var cleanedName = fieldName.TrimStart('m', '_');
            var propertyName = char.ToUpper(cleanedName[0]) + cleanedName.Substring(1);
            var eventName = $"On{propertyName}Changed";

            var source = $@"
public partial class {className}
{{
    public event System.Action<{fieldType}> {eventName};

    public {fieldType} {propertyName}
    {{
        get => {fieldName};
        set
        {{
            if ({fieldName} != value)
            {{
                {fieldName} = value;
                {eventName}?.Invoke({fieldName});
            }}
        }}
    }}
}}";
            context.AddSource($"{className}_{fieldName}_Subscription.cs", source);
        }
    }

This code does the following:

  • The generator checks that the context.SyntaxReceiver is the expected type for collecting the necessary syntax nodes.
  • It then iterates through the fields with the Subscription attribute collected earlier in the SubscriptionSyntaxReceiver.
  • For each field, based on the template, it forms the code for an event and a property that respond to changes in this field.
  • The generated code is added to the compilation context as a new source file, ensuring the integration of the generated properties and events into the class.

The output of our code generator will be the desired result. The generated code:

public partial class MyClass
{
    event System.Action<int> OnCounterChanged;
    public int Counter
    {
        get => m_Counter;
        set
        {
            if (m_Counter != value)
            {
                m_Counter = value;
                OnCounterChanged?.Invoke(m_Counter);
            }
        }
    }
}

More flexible approach – SyntaxNodes

There are situations when it’s not possible to immediately write all the code as a string template, or we want to write method, class, and other syntax factories directly using syntax nodes, step by step. In the context of the Roslyn compiler, a SyntaxNode represents an element of the abstract syntax tree (AST), reflecting the structure of a program’s source code. Each SyntaxNode is immutable, meaning its state cannot be changed after creation. This immutability ensures reliability and predictability when analyzing and transforming code, as any “changes” or operations on the tree result in the creation of new nodes, rather than altering existing ones. This approach simplifies code handling, making the process more secure and understandable.

Roslyn allows us to combine this approach. Part of the code can be created from text templates, and part through these nodes. Thus, where necessary, we can create these nodes, expand their content, and reuse them like small building blocks. Where it’s convenient, we use node-based code construction, and where it’s preferable, we use template-based approaches.

Let’s give our generator a little upgrade by mixing both methods, making it more flexible. Just a heads-up: diving into syntax nodes gives us awesome control but can get a bit difficult to read.

Generator for an empty partial class:

    public static ClassDeclarationSyntax GeneratePartialClass(string className)
    {
        return SyntaxFactory.ClassDeclaration(className)
            .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword), SyntaxFactory.Token(SyntaxKind.PartialKeyword));
    }

Now, the method for generating an event:

 public EventFieldDeclarationSyntax GenerateEvent(string fieldType, string eventName)
    {
        var eventIdentifier = SyntaxFactory.Identifier(eventName);
        var actionType = SyntaxFactory.GenericName(
                SyntaxFactory.Identifier("System.Action"))
            .WithTypeArgumentList(
                SyntaxFactory.TypeArgumentList(
                    SyntaxFactory.SingletonSeparatedList<TypeSyntax>(
                        SyntaxFactory.IdentifierName(fieldType))));

        return SyntaxFactory.EventFieldDeclaration(
            SyntaxFactory.VariableDeclaration(actionType)
                .WithVariables(
                    SyntaxFactory.SingletonSeparatedList(
                        SyntaxFactory.VariableDeclarator(eventIdentifier))));
    }

And for the property, let’s parse the code completely (we could buildup it like event from get/set accessors, property declaration, mofifiers, conditions etc, but let’s parse it for example as complete node from template):

    public PropertyDeclarationSyntax GenerateProperty(string fieldType, string propertyName, string fieldName, string eventName)
    {
        var propertyCode = $@"
  public {fieldType} {propertyName}
     {{
         get => {fieldName};
         set
         {{
             if ({fieldName} != value)
             {{
                 {fieldName} = value;
                 {eventName}?.Invoke({fieldName});
             }}
         }}
     }}
";
        return (PropertyDeclarationSyntax)SyntaxFactory.ParseMemberDeclaration(propertyCode);
    }

Let’s put the pieces of our code generator together and see what we’ve crafted. Here’s the result we’ve achieved:

 public void Execute(GeneratorExecutionContext context)
    {
        if (context.SyntaxReceiver is not SubscriptionSyntaxReceiver receiver)
            return;

        foreach (var field in receiver.FieldsWithSubscriptionAttribute)
        {
            var fieldName = field.Declaration.Variables.First().Identifier.Text;
            var fieldType = ((PredefinedTypeSyntax)field.Declaration.Type).Keyword.Text;
            var className = ((ClassDeclarationSyntax)field.Parent).Identifier.Text;
            var cleanedName = fieldName.TrimStart('m', '_');
            var propertyName = char.ToUpper(cleanedName[0]) + cleanedName.Substring(1);
            var eventName = $"On{propertyName}Changed";

            //create event and property
            var eventDeclaration = GenerateEvent(fieldType, eventName);
            var propertyDeclaration = GenerateProperty(fieldType, propertyName, fieldName, eventName);

            //create class and add event and property to it
            var classNode = GeneratePartialClass(className)
                .AddMembers(eventDeclaration)
                .AddMembers(propertyDeclaration);

            //create compilation unit and add it to the build
            var unit = SyntaxFactory.CompilationUnit().AddMembers(classNode);
            var code = unit.NormalizeWhitespace().ToFullString();

            context.AddSource($"{className}_{fieldName}_Subscription.cs", code);
        }
    }

So, we’ve explored two ways of code generation, enabling us to automate the creation of subscriptions. What’s next? This method allows us to tackle a broad range of code generation tasks, such as implementing patterns, crafting class converters, automating interface implementations, and facilitating Dependency Injection setups. Beyond just generating code, this approach empowers us to analyze existing code, identifying potential issues before they become problems. This dual capability of generating and scrutinizing code not only streamlines development but also enhances code quality and reliability.

Microsoft learning materials: https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/source-generators-overview

Unity documentation: https://docs.unity3d.com/Manual/roslyn-analyzers.html

For more examples you can also check my plugin: https://github.com/dr-vij/UTools-Generators