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:
- When you’re writing tests for the generator.
- 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:
- Write high-level tests that verify the presence and/or behavior of the final generated code elements.
- 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!