Don't write code, generate it!

Posted on Fri 06 September 2024 in Coding

A while back, I got fed up with writing code. Not writing code in general, mind you, but I did face a situation where I needed to write a lot of boilerplate code, and having to write such code is a certain way of losing interest in software development. Not only is it tedious - repetitive code tends to be error-prone, requires a lot of equally repetitive tests, and thus usually ends up being quite a large maintenance burden.

This was a C# project, and after contemplating the task for a while, I realized that this was a good opportunity for creating a source generator. A source generator runs during compilation and emits code that becomes part of the compilation. Exactly what I needed!

This post documents the steps I went through to build a source generator, with extra focus on different ways of writing automated tests for the generator. Since I’m a strong proponent of using Test-Driven Development (TDD), the ability to write automated tests is crucial to me, and luckily there are many options on the table for that.

Side note: One situation where writing automated tests is difficult, is when you have to interface with a framework or library that was not written with testability in mind. I’m happy that Microsoft thought about this aspect!

It should be noted that this post is not a comprehensive guide to writing source generators. There are plenty of good guides and documentation available online. Also, the post is not a TDD post, but as a TDD aficionado, I really can’t help but approach it from that angle, as you’ll see.

The code examples use modern C# (12.0) and .NET 8, so if you’re stuck on older versions, you have to adjust accordingly.

A more complete version of the code in the post is available in this GitHub repository.

Use case

To have a use case to work with, consider a class that defines color models:

public static class ColorModels {
    public static string[] RGB = ["red", "green", "blue"];
    public static string[] CMYK = ["cyan", "magenta", "yellow", "black"]; // k is for "key", which is black
}

Based on a color model, we want to generate a wrapper class that allows us to extract values from a dictionary that may or may not contain values for the components of the model:

public class RGBAccessor(IDictionary<string, double> values) {
    public double? Red => values.TryGetValue("red", out var value) ? value : null;
    public double? Green => values.TryGetValue("green", out var value) ? value : null;
    public double? Blue => values.TryGetValue("blue", out var value) ? value : null;
}

This particular piece of code isn’t too bad to write (though tests are omitted here), but as the number of color models and color components grow, more similar code has to be written, and if a color model changes, the corresponding accessor must be kept in sync.

Project setup

We start by creating a solution and the relevant projects - one class library project to house the source generator, and one unit test project. It’s important that the project for the source generator targets netstandard2.0. The resulting structure looks like this:

\ source-generator-lab/
  |- source-generator-lab.sln
  |- generator/
  |  \ generator.csproj
  \- generator.tests/
     \ generator.tests.csproj

The examples use NUnit as test library, but that’s not important. Any modern test/assertion framework should do.

Once the solution structure is in place, we modify the project file of the generator project so that the primary property group contains the following:

<PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <!-- Change the language version as needed. -->
    <LangVersion>12.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
</PropertyGroup>

The IsRoslynComponent setting is necessary for debugging to work in Visual Studio (Rider doesn’t care).

The EnforceExtendedAnalyzerRules setting prevents us from using certain banned APIs in the source generator.

We also need to add references to some NuGet packages:

<ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
</ItemGroup>

There may be newer versions of course; these are the latest versions at the time of writing this post.

Next, we edit the project file of the generator.tests project to add an item group with a reference to generator that looks like this:

<ItemGroup>
    <ProjectReference Include="..\generator\generator.csproj"
        OutputItemType="Analyzer"
        ReferenceOutputAssembly="true"
    />
</ItemGroup>

The value of ReferenceOutputAssembly determines if the project should be a runtime reference or not. There are two cases when having the generator assembly as a runtime reference is useful:

  1. When you’re writing tests for the generator.
  2. When the generated code contains references to types defined in the generator assembly.

If both cases apply, I recommend making the generator class internal and using InternalsVisibleTo to make it available to the test project.

Furthermore, we modify the primary PropertyGroup of generator.tests so that it contains:

<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>

This causes generated sources to be written into a subfolder of generated.tests/obj, which is helpful for troubleshooting. The obj folder is normally excluded from source control. If you for some reason want to add the generated source to source control, take a look at the CompilerGeneratedFilesOutputPath setting.

In the generator.tests project, we add a file named GlobalUsings.cs that contains (modify accordingly if you don’t use NUnit):

global using System.Reflection;
global using System.Text;
global using Microsoft.CodeAnalysis;
global using Microsoft.CodeAnalysis.CSharp;
global using Microsoft.CodeAnalysis.Text;
global using NUnit.Framework;
global using generator;

This way, we don’t have to repeat those usings in individual test files.

Generator class (scaffolding)

In the generator project, we add an attribute class that will be a marker attribute that triggers source generation:

namespace generator;

[AttributeUsage(AttributeTargets.Class)]
public class ColorModelsAttribute : Attribute;

There is another option for providing the marker attribute - it can be generated using context.RegisterPostInitializationOutput. If you prefer that approach, the generator assembly does not have to be a runtime dependency.

Next, we add the actual source generator class, ColorModelGenerator. We add the [Generator] attribute and let it implement the IIncrementalGenerator interface:

using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace generator;

[Generator]
internal class ColorModelGenerator : IIncrementalGenerator {
    public void Initialize(IncrementalGeneratorInitializationContext context) {
    }
}

The IIncrementalGenerator interface is a modern API for defining a source generator. It’s also possible to use the ISourceGenerator interface, which requires a slightly different generator implementation.

Since the generator assembly contains the marker attribute, it needs to be a runtime reference as well. As noted before, it’s a good idea to make the generator class internal in this case, so we do that.

Finally, we add a file named InternalsVisibleTo.cs with the following contents, so that unit tests can reference the generator class directly despite its internal visibility.

using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("generator.tests")]

In Visual Studio, if you expand Dependencies/Analyzers for generator.tests, you should see a reference to generator. In Rider, look in Dependencies/.NET 8.0/Analyzers.

Writing tests

There are at least two different ways of writing tests for a source generator:

  1. Write high-level tests that verify the presence and/or behavior of the final generated code elements.
  2. Write unit tests that verify the raw output of the source generator.

Pros of the first way are:

  • It’s possible to test the behavior of the generated code elements.
  • The tests become less sensitive to changes in the generator output.

Pros of the second way are:

  • It’s possible to test diagnostics (e.g., errors) emitted by the source generator.
  • It’s easier to verify exactly what code is generated.
  • It’s easier to debug the source generator from the tests.

1. Test the generated code elements

To try the first way, we add a new class named ColorModelGeneratorWay1Test to the test project. (Normally, I don’t use numbers in test fixture names or test names, but this is way 1, after all.)

We modify ColorModelGeneratorWay1Test as follows (if you use a different test framework, adjust as necessary):

[TestFixture]
public class ColorModelGeneratorWay1Test {
    [Test]
    public void Should_generate_an_accessor_for_a_color_model() {
        var type = Type.GetType("colormodels.EmptyColorModelAccessor");
        Assert.That(type, Is.Not.Null);
    }

    [ColorModels]
    public static class TestColorModels {
        public static string[] Empty = [];
    }
}

This test will fail since there is no EmptyColorModelAccessor class generated for the Empty color model yet. However, it will compile and run, since we use Type.GetType rather than accessing the class statically. As we test-drive further, we can at some point start using the class directly.

There’s a philosophical aspect to this. It can be argued that not being able to compile is also a variant of the Red step in Red-Green-Refactor. Whatever works for you is good, I think!

Note that the test assumes that the generated accessor class ends up in a colormodels namespace. Furthermore, the test assumes that the generator will recognize the TestColorModels class based on the [ColorModels] marker attribute and use its field. The recommended approach is to trigger source generation based on an attribute, and there is an optimized mechanism for that that we’ll use (see this section in the incremental generators documentation).

It seems that msbuild caches generated code quite aggressively. This is problematic when working with tests of this kind, since they may run against old generated code. In Rider, it’s sufficient to rebuild the test project to refresh the generated code.

2. Test the output of the source generator

To be able to try the second way, it’s beneficial to create a verifier helper. I found Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing.NUnit via the source generators cookbook, but sadly it depends indirectly on a version of Microsoft.CodeAnalysis.Common that is too old. Luckily, it’s easy for us to roll our own verifier.

We start by defining a base class for the verifier:

using System.Collections.Immutable;

namespace generator.tests;

// A verifier helper base class that is generic on a generator type (that has a default constructor).
public abstract class VerifierBase<TGenerator> where TGenerator : IIncrementalGenerator, new() {
    // References to assemblies needed for the compilation.
    // Adjust the System.Runtime version as needed.
    private static readonly MetadataReference SystemRuntimeReference =
        MetadataReference.CreateFromFile(Assembly.Load("System.Runtime, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a").Location);
    private static readonly MetadataReference NetStandard =
        MetadataReference.CreateFromFile(Assembly.Load("netstandard, Version=2.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51").Location);
    private static readonly MetadataReference SystemPrivateCoreLib =
        MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location);
    private static readonly MetadataReference Generator =
        MetadataReference.CreateFromFile(typeof(TGenerator).GetTypeInfo().Assembly.Location);

    private static Compilation CreateCompilation(string source) =>
        CSharpCompilation.Create("compilation",
            [CSharpSyntaxTree.ParseText(source)],
            [
                SystemPrivateCoreLib,   // needed for predefined types like System.Object
                SystemRuntimeReference, // needed for Attribute
                NetStandard,            // needed for Attribute
                Generator,              // needed for ColorModelsAttribute
            ],
            new CSharpCompilationOptions(OutputKind.ConsoleApplication));

    protected GeneratorDriverRunResult RunGenerator(string inputCode, out Compilation outputCompilation, out ImmutableArray<Diagnostic> diagnostics) {
        // Create the input compilation that the generator will act on.
        var inputCompilation = CreateCompilation(inputCode);

        // Create an instance of the generator.
        var generator = new TGenerator();

        // Create the driver that will control the generation.
        GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);

        // Run the generation pass. The driver is immutable, and all calls return a new driver instance.
        driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out outputCompilation,
            out diagnostics);

        return driver.GetRunResult();
    }
}

The base verifier creates metadata references needed for compiling the input code. Most of them are needed because ColorModels attribute is defined in the same assembly as the generator.

We can test the generator output by looking at the generated source code, or by looking at the generated syntax tree. Let’s start with source code.

2A. Test the generated source code text

Here’s a verifier that can be constructed with input code, expected generated sources, and some other settings. It compares the output of the generator line by line.

namespace generator.tests;

public class TextVerifier<TGenerator> : VerifierBase<TGenerator> where TGenerator : IIncrementalGenerator, new() {
    // The input code to run through the generator.
    public required string InputCode { get; init; }

    // The sources we expect to be generated, as a list of tuples. The hint name is
    // typically the filename of a generated source file.
    public required (string hintName, string expectedCode)[] ExpectedCode { get; init; }

    // Whether to ignore line comments when comparing actual and expected source code.
    public bool IgnoreLineComments { get; init; } = true;

    // List of error IDs to ignore. CS5001 is "Program does not contain a static 'Main' method suitable for an entry point".
    public IList<string> IgnoredErrors { get; init; } = ["CS5001"];

    // Runs the verifier.    
    public void Run() {
        // Input validation omitted for brevity.

        // Compile the input code.        
        var runResult = RunGenerator(InputCode, out var outputCompilation, out var diagnostics);

        // We get diagnostics at this point if there are problems with the input source code. Fail on any
        // non-ignored error.
        var errors = outputCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error && !IgnoredErrors.Contains(d.Id)).ToList();
        Assert.That(errors, Is.Empty);

        // We'll see a diagnostic at this point if the generator throws an exception,
        // or if the generator emits a diagnostic.
        Assert.That(diagnostics, Is.Empty);

        // Get the result from the generator.
        var generatorResult = runResult.Results.FirstOrDefault();

        // Iterate over the expected source code files.
        foreach (var (hintName, code) in ExpectedCode) {
            // GeneratedSourceResult is a struct, so check that we get a valid result.
            var generated = generatorResult.GeneratedSources.FirstOrDefault(r => r.HintName == hintName);
            if (generated.HintName != hintName) {
                throw new InvalidOperationException($"No generated source found for: {hintName}");
            }

            // Convert the expected code into source text.
            var expectedSourceText = SourceText.From(code, Encoding.UTF8, SourceHashAlgorithm.Sha256);

            // Extract lines from the generated and expected source texts, for comparison.
            var generatedLines = getLines(generated.SourceText);
            var expectedLines = getLines(expectedSourceText);

            // Compare the lines using a collection assertion, to get as good failure
            // output as possible.
            Assert.That(generatedLines, Is.EqualTo(expectedLines), () => $"Line mismatch for {hintName}");
        }

        return;

        // This local function extracts lines from a source text, ignoring empty lines and
        // possibly ignoring line comments. Lines are trimmed so that comparison is not sensitive
        // to trailing whitespace.
        IList<string> getLines(SourceText text)
            => text.Lines
                .Select(l => l.ToString().Trim())
                .Where(l => l != "")
                .Where(l => !IgnoreLineComments || !l.TrimStart().StartsWith("//"))
                .ToList();
    }
}

Using this verifier, we can write a unit test for the generator. We create a class named ColorModelGeneratorWay2ATest, and write the first test as follows:

[TestFixture]
public class ColorModelGeneratorWay2ATest {
    [Test]
    public void Should_generate_an_accessor_class_for_a_color_model() {
        var verifier = new TextVerifier<ColorModelGenerator> {
            InputCode = """
                        [generator.ColorModels]
                        public static class ColorModels {
                            public static string[] Empty = [];
                        }
                        """,

            ExpectedCode =
            [
                (
                    "colormodels.g.cs",
                    """
                    #nullable enable
                    using System;
                    using System.Collections.Generic;

                    namespace colormodels;

                    public class EmptyColorModelAccessor(IDictionary<string, double> values) {
                    }                                          
                    """)
            ]
        };

        verifier.Run();
    }
}

As you can see, the test is much more detailed than the previous one. Since we compare source text, we need to include usings, namespace etc. in the expected output. Of course, it would be possible to modify the verifier helper to ignore such lines in addition to empty lines and comment lines, but even so, this approach is sensitive to things like placement of curly braces. Old tests break easily if you make even small modifications to the generated code.

A benefit of writing tests like this is that it’s very easy to visually (e.g., in a code review) determine that the expected code is correct. Another benefit is that we (with some modifications to the verifier) can verify that no unexpected code is being generated.

2B. Test the generated syntax tree

We can create a slightly different verifier that allows us to write tests against the generated syntax tree. It can look like this:

namespace generator.tests;

public class SyntaxVerifier<TGenerator> : VerifierBase<TGenerator> where TGenerator : IIncrementalGenerator, new() {
    // The input code to run through the generator.
    public required string InputCode { get; init; }

    // The sources we expect to be generated, as a list of tuples. The hint name is
    // typically the filename of a generated source file.
    public required (string hintName, Action<SyntaxTree>)[] CodeTesters { get; init; }

    // List of error IDs to ignore. CS5001 is "Program does not contain a static 'Main' method suitable for an entry point".
    public IList<string> IgnoredErrors { get; init; } = ["CS5001"];

    // Runs the verifier.
    public void Run() {
        // Input validation omitted for brevity.

        // Compile the input code.        
        var runResult = RunGenerator(InputCode, out var outputCompilation, out var diagnostics);

        // We get diagnostics at this point if there are problems with the input source code. Fail on any
        // non-ignored error.
        var errors = outputCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error && !IgnoredErrors.Contains(d.Id)).ToList();
        Assert.That(errors, Is.Empty);

        // We'll see a diagnostic at this point if the generator throws an exception,
        // or if the generator emits a diagnostic.
        Assert.That(diagnostics, Is.Empty);

        // Get the result from the generator.
        var generatorResult = runResult.Results.FirstOrDefault();

        // Iterate over the expected source code files.
        foreach (var (hintName, tester) in CodeTesters) {
            // GeneratedSourceResult is a struct, so check that we get a valid result.
            var generated = generatorResult.GeneratedSources.FirstOrDefault(r => r.HintName == hintName);
            if (generated.HintName != hintName) {
                throw new InvalidOperationException($"No generated source found for: {hintName}");
            }

            tester(generated.SyntaxTree);
        }
    }
}

It’s slightly simpler than the text-based verifier. Instead of accepting tuples that contain expected code, it contains tuples with syntax tree testers - where “tester” in this case is nothing more than an action that receives a SyntaxTree.

Based on this, we can create ColorModelGeneratorWay2BTest, which looks like this:

[TestFixture]
public class ColorModelGeneratorWay2BTest {
    [Test]
    public void Should_generate_an_accessor_class_for_a_color_model() {
        var verifier = new SyntaxVerifier<ColorModelGenerator> {

            InputCode = """
                        [generator.ColorModels]
                        public static class TestColorModels {
                            public static string[] Empty = [];
                        }
                        """,

            CodeTesters =
            [
                (
                    "colormodels.g.cs",
                    tree => {
                        var classNames = tree.GetRoot().DescendantNodes()
                            .OfType<ClassDeclarationSyntax>()
                            .Select(cls => cls.Identifier.Text)
                            .ToList();
                        Assert.That(classNames, Does.Contain("EmptyColorModelAccessor"));
                    }
                )
            ]
        };

        verifier.Run();
    }
}

The syntax tree tester enumerates all class declarations found in the syntax tree, and tests that there is one with the correct name. This way of testing the generator output is much more robust than the text-based way, though the assertion is not as easy to read (nor to write, as it requires knowledge about the syntax tree).

Another benefit is that specific assertions like the one above can have a better failure output compared to a line diff. For example, if the generator misspells the class name as Accesor, these are the failure outputs of the tests written so far:

Way 1:

Expected: not null
  But was:  null

Way 2A:

Line mismatch for colormodels.g.cs
  Expected and actual are both <System.Collections.Generic.List`1[System.String]> with 6 elements
  Values differ at index [4]
  Expected string length 74 but was 73. Strings differ at index 33.
  Expected: "...EmptyColorModelAccessor(IDictionary<string, double> values) {"
  But was:  "...EmptyColorModelAccesor(IDictionary<string, double> values) {"
  ----------------------------------^

Way 2B:

Expected: some item equal to "EmptyColorModelAccessor"
  But was:  < "EmptyColorModelAccesor" >

I’ll admit that 2A is clear enough in this particular case, but it also contains some noise. 2B is concise and easy to understand, I think.

2C. Testing diagnostics

Let’s look at testing generator diagnostics. As mentioned, doing so is not possible if we test the generated code elements (way 1). If the generator emits an error diagnostics, there may be no generated code element to test, and there will be an error in the test project, preventing us from running the tests.

Let’s look at a verifier that allows us to test diagnostics:

namespace generator.tests;

public class DiagnosticVerifier<TGenerator> : VerifierBase<TGenerator> where TGenerator : IIncrementalGenerator, new() {
    // The input code to run through the generator.
    public required string InputCode { get; init; }

    // List of error IDs to ignore. CS5001 is "Program does not contain a static 'Main' method suitable for an entry point".
    public IList<string> IgnoredErrors { get; init; } = ["CS5001"];

    // Runs the verifier. The action will receive the diagnostics emitted by the generator.
    public void Run(Action<IEnumerable<Diagnostic>> tester) {
        // Input validation omitted for brevity.

        // Compile the input code (the result is not used).
        _ = RunGenerator(InputCode, out var outputCompilation, out var diagnostics);

        // We get diagnostics if there are problems with the input source code. Fail on any
        // non-ignored error.
        var errors = outputCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error && !IgnoredErrors.Contains(d.Id)).ToList();
        Assert.That(errors, Is.Empty);

        // Test the emitted diagnostics
        tester(diagnostics);
    }
}

Problems with the input source code will fail a test, unless ignored. However, emitted diagnostics will be passed to a tester action that allows to verify them.

Let’s write a test that emits an error if a color is a known trademarked color, such as Vantablack™:

[TestFixture]
public class ColorModelGeneratorWay2CTest {
    [Test]
    public void Should_detect_trademarked_color() {
        var verifier = new DiagnosticVerifier<ColorModelGenerator> {

            InputCode = """
                        [generator.ColorModels]
                        public static class TestColorModels {
                            public static string[] Sneaky = ["red", "Vantablack"];
                        }
                        """
        };

        verifier.Run(diags =>
        {
            var messages = diags.Select(diag => diag.GetMessage()).ToList();
            Assert.That(messages, Does.Contain("The color 'Vantablack' in the 'Sneaky' color model is trademarked"));
        });
    }
}

There are of course other aspects to test as well, such as the diagnostic severity and location (i.e. which code element it refers to), but I think the message is the first thing to look at.

As noted in the verifier comments, any exception thrown by the generator will also be reported as a diagnostic. For example, if the generator throws ArgumentException when it encounters a color that contains invalid characters, the (testable) diagnostic may look as follows:

warning CS8785: Generator 'ColorModelGenerator' failed to generate source.
It will not contribute to the output and compilation errors may occur as a result.
Exception was of type 'ArgumentException' with message '// is not a valid color'.

Generator implementation

Let’s look at the implementation of the generator. I won’t go into details about incremental generators - you can find good documentation here.

Initialize

Let’s implement the Initialize method. It can look like this:

    public void Initialize(IncrementalGeneratorInitializationContext context) {
        // Define a syntax provider to filter nodes based on the ColorModels attribute
        // and extract the color model variables.
        // The predicate is not needed, so it just returns true.
        var variablesProvider = context.SyntaxProvider.ForAttributeWithMetadataName(
                typeof(ColorModelsAttribute).FullName!,
                predicate: (_, _) => true,
                transform: (ctx, _) => GetVariables(ctx))
            .SelectMany((fields, _) => fields)
            .Collect();

        // Register the source output. The delegate receives the result from executing the provider.
        context.RegisterSourceOutput(variablesProvider, (spc, variables) => Execute(spc, variables));
    }

    private static IEnumerable<VariableDeclaratorSyntax> GetVariables(GeneratorAttributeSyntaxContext context) { ... }

The ForAttributeWithMetadataName method creates a provider that looks for attributes of the given type. When an attribute node is found, it’s transformed using the GetVariables method. As can be seen in the signature of GetVariables, it returns an enumerable of variables, which is why SelectMany is used to flatten all the transformed enumerables into a single one. The final provider result is passed to the Execute method.

The GetVariables method looks like this:

    private static IEnumerable<VariableDeclaratorSyntax> GetVariables(GeneratorAttributeSyntaxContext context) {
        if (context.TargetNode is not ClassDeclarationSyntax classDeclarationSyntax) yield break;

        var fields = classDeclarationSyntax.Members.OfType<FieldDeclarationSyntax>();
        foreach (var fieldDecl in fields) {
            foreach (var variable in fieldDecl.Declaration.Variables) {
                yield return variable;
            }
        }
    }

Side note: A field can have multiple variables, e.g. if you write public static string[] RGB = [...], CMYK = [...];.

We expect the target node to be a class, since the ColorModels attribute only targets classes, and the use of pattern matching is a convenient way of checking that and exiting if something is wrong. Next, we list fields in the class, and for each field, we list its variables.

This is the minimum required by the tests written so far, but it’s a good idea to make some additional filtering (driven by tests, of course):

  • Require the field to be public static.
  • Require the field to have type string[].

Execute

In the Execute method, we act on the collected variables. Here is an implementation that satisfies the current test suite:

private static void Execute(SourceProductionContext context, IEnumerable<VariableDeclaratorSyntax> variables) {
    // Start building the code.
    var sourceBuilder = new StringBuilder("""
                                          #nullable enable
                                          using System;
                                          using System.Collections.Generic;

                                          namespace colormodels;

                                          """);

    // Iterate over the collected color model variables.
    foreach (var variable in variables) {
        var colorNames = GetStrings(variable);
        var modelName = variable.Identifier.ValueText;

        // Generate the start of an accessor class for the current model.
        sourceBuilder.AppendLine($$"""
                                   public class {{modelName}}ColorModelAccessor(IDictionary<string, double> values) {
                                   """);

        // Iterate over colors to generate properties.
        foreach (var color in colorNames) {
            if (TrademarkedColors.Contains(color)) {
                // Trademarked color found!
                var diagnostic = Diagnostic.Create(
                    TrademarkedColorDescriptor,
                    variable.GetLocation(),
                    color, categoryName);

                context.ReportDiagnostic(diagnostic);
            }

            // TODO: Append the actual color component property here!
        }

        sourceBuilder.AppendLine("}");
    }

    // Emit the actual code.
    var generatedCode = sourceBuilder.ToString();
    context.AddSource("colormodels.g.cs", SourceText.From(generatedCode, Encoding.UTF8));
}

Since we haven’t written any tests that verify the presence or behavior of a property for a color model component, that code is omitted for now.

The GetStrings function and the descriptor for the diagnostic are needed as well:

    private static string[] GetStrings(VariableDeclaratorSyntax variable) {
        if (variable.Initializer?.Value is CollectionExpressionSyntax collection) {
            return collection.Elements.SelectMany(elem =>
                elem is ExpressionElementSyntax { Expression: LiteralExpressionSyntax literal }
                    ? [literal.Token.ValueText]
                    : Array.Empty<string>()
            ).ToArray();
        }

        return [];
    }

    private static readonly DiagnosticDescriptor TrademarkedColorDescriptor = new(
        id: "CM001",
        title: "Trademarked Color",
        messageFormat: "The color '{0}' in the '{1}' color model is trademarked",
        category: "Syntax",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true);

    private static string[] TrademarkedColors = ["Vantablack", "Barbie Pink"];

GetStrings verifies that the variable is a collection variable but nothing beyond that. As noted, the filtering of fields should make sure that the type is correct.

Debugging

To be able to debug the source generator outside of the unit tests (e.g. when writing behavioral tests like way 1), we can add a Properties folder to the generator project, and add a launchSettings.json file in that folder. The file should look like this:

{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "profiles": {
    "Generators": {
      "commandName": "DebugRoslynComponent",
      "targetProject": "..\\generator.tests\\generator.tests.csproj"
    }
  }
}

(Note that backslashes have to be escaped, unlike in csproj files.)

For debugging to work in Visual Studio, you may have to add the .NET Compiler Platform SDK component to your installation.

To start debugging in Rider, click on the small play button that appears next to the “Generators” line in launchSettings.json. In Visual Studio, right-click on the generators project, select Debug and then “Start New Instance”.

Final words

Source generators are powerful tools as they allow us to write less “manual” code, thus reducing the risk for bugs. Using them correctly, we can lift our gaze and work with high-level concepts, but still interface with the concepts in the codebase. The use case in this post is quite simple, but there are many other interesting use cases. For example, properties in a class can be augmented to track changes, or a simple CRUD UI, API, and storage can be generated from a model class.

And, as icing on the cake (or “cream on the mashed potatoes” as we say in Sweden), it’s easy to write tests for our source generators, just as we would any other code!

Happy generating!