Skip to content
Bruno Sonnino
Menu
  • Home
  • About
Menu

C# Source Code Generators

Posted on 11 June 2023

C# 9 introduced a new feature that allows you to inspect user code as it is being compiled and generate new C# source files that are added to the compilation. This enables you to write code that runs during compilation and produces additional source code based on the analysis of your program. In this blog post, I will explain what C# source code generators are, why they are useful, and how to use them in your projects.

What are C# source code generators?

A C# source code generator is a piece of code that you can write to hook into the compilation pipeline and generate additional source files that will be compiled. The process is like this one:


(From https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/)

When the compilation runs, the source code is passed to our code generator, which analyzes it and generates new code that is added to the current code and compiled with it, generating an executable that has both the user code and the generated one.

This allows you to make your code more performant and robust: one scenario in which it could be used is to remove the need for reflection in your code: all code is generated at compile time and you don't need to use reflection to work with the class data. Another use is to remove boilerplate code: you can add a generator to add all the extra code that's needed for a specific situation. For example, in my MVVM Toolkit 8 article I've shown the use of removing all the boilerplate code for the INotifyPropertyChanged interface implementations. In fact, this code:

[ObservableProperty]
private Customer _selectedCustomer;
C#

would create the property with a getter and setter, which triggers the PropertyChanged event in the setter.

Creating a C# source code generator

We will now create a simple Code Generator. The source code generator is a .NET Standard 2.1 dll that will be added to the main project. To create the dll, you should use these commands:

dotnet new classlib -o HelloGenerator
cd HelloGenerator
code .
Dos

Add the packages Microsoft.CodeAnalysis.Analyzers and Microsoft.CodeAnalysis.CSharp:

dotnet add package Microsoft.CodeAnalysis.Analyzers
dotnet add package Microsoft.CodeAnalysis.CSharp
Dos

The csproj file must be changed to set the new target version and be used as a generator:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.1</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <LangVersion>latest</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.6.0" />
  </ItemGroup>

</Project>

In VS Code, create a new file and name it HelloGenerator.cs. The structure of the file is this:

using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

[Generator]
public class HelloGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
        // Initialization code 
    }

    public void Execute(GeneratorExecutionContext context)
    {
        // Code that will be executed at compile time
    }
}
C#

We will create a HelloGenerator class that implements the ISourceGenerator interface and add the [Generator] attribute. This interface has two methods:

  • Initialize, to initialize the generator. This method is called once, when your generator is loaded. You can use this method to register callbacks for syntax or semantic changes in the user code.
  • Execute, where the source code will be generated. The GeneratorExecutionContext parameter will provide information about the code being compiled.

Our sample generator looks like this:

using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace HelloWorldGenerator;

[Generator]
public class HelloGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
        // No initialization required for this one
    }

    public void Execute(GeneratorExecutionContext context)
    {
        // Create the source to inject
        var sourceBuilder = new StringBuilder(@"
        using System;
namespace HelloWorldGenerated
{
    public static class HelloWorld
    {
        public static void SayHello()
        {
            Console.WriteLine(""Hello from generated code!"");
        }
    }
}");
        // Add the source file to the compilation
        context.AddSource("HelloWorld", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));
    }
}
C#

We don't need any initialization, and the execution will create a single file with a static class HelloWorld that has a SayHello method.

We can run dotnet build to build the dll. Once you do it, we can create the code that uses it. Create a new console project and name it HelloTest. In this project, we must reference the HelloGenerator project:

cd ..
dotnet new console -o HelloTest
cd HelloTest
dotnet add reference ..\HelloGenerator\HelloGenerator.csproj
code .
Dos

In the HelloTest project, we must change the reference for the generator project:

<Project Sdk="Microsoft.NET.Sdk">

  <ItemGroup>
    <ProjectReference Include="..\HelloGenerator\HelloGenerator.csproj" 
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false"/>
  </ItemGroup>

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

The program for this project is:

HelloWorldGenerated.HelloWorld.SayHello();
C#

When you run it, you get something like this:

As you can see, the source generator created a new class called HelloWorld in a namespace called HelloWorldGenerated, and added a static method called SayHello that prints a message to the console. The user code then referenced this class and called its method.

This is a very simple example, but it demonstrates the basic steps of creating and using a source generator. In the next section, we will see how to write a more complex source generator that implements a specific scenario.

Json serializer generator

Let's say we have a class like this:

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public int Age { get; set; }
}
C#

And we want to implement a method ToJson, that will take an instance of the Person class and generate a json string with the data. We could create an extension method, like this one:

using System.Text;

public static class JsonExtensions
{
    public static string ToJson<T>(this T element)
    {
        var properties = typeof(T).GetProperties();
        var builder = new StringBuilder();
        builder.Append("{");
        foreach (var property in properties)
        {
            var name = property.Name;
            var value = property.GetValue(element);
            builder.Append($"\"{name}\":\"{value}\",");
        }
        builder.Remove(builder.Length - 1, 1); // Remove trailing comma
        builder.Append("}");
        return builder.ToString();
    }
}
C#

This approach works fine, but it requires reflection, which has some performance overhead.

Another approach is to use a source generator, where you could write some code that analyzes the Person class at compile time and generates a new class that implements a ToJson method especially written for it, without having to resort to reflection. This approach is more performant, because everything is generated at compile time and there is no need to do anything at runtime.

At the end, this generator will produce a new file with the following extension methods:

public static class PersonExtensions
{
     public static string ToJson(this Person person)
    {
        // Generate code to serialize person to JSON
    }

    public static Person FromJson(string json)
    {
        // Generate code to deserialize person from JSON
    }
}
C#

To write this generator, we need to do the same thing that we did in the previous project:

Create a new classlib project, add the packages Microsoft.CodeAnalysis.Analyzers and Microsoft.CodeAnalysis.CSharp and change the project file be used as a generator:

dotnet new classlib -o JsonGenerator
cd JsonGenerator
code .
dotnet add package Microsoft.CodeAnalysis.Analyzers
dotnet add package Microsoft.CodeAnalysis.CSharp
Dos
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <LangVersion>latest</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.6.0" />
  </ItemGroup>

</Project>

Create a new file JsonGenerator.cs and create a new class that implements the ISourceGenerator interface and add the [Generator] attribute to it.

using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

[Generator]
public class JsonGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
        // Initialization code 
    }

    public void Execute(GeneratorExecutionContext context)
    {
        // Code that will be executed at compile time
    }
}
C#

Implement the Initialize method to register a callback for syntax receiver creation. This callback will create an instance of a custom syntax receiver class that will collect all the classes with the [Serializable] attribute in the user code and create this class, that implements the ISyntaxReceiver interface and overrides the OnVisitSyntaxNode method. This method will be called for every syntax node in the user code and will check if it is a class declaration with the [Serializable] attribute. If this is the case, it will be added to the CandidateClasses list, that will be used in the Execute method.

public class JsonGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
        // Register a callback for syntax receiver creation
        context.RegisterForSyntaxNotifications(() => new SerializationSyntaxReceiver());
    }
    
    ...
}

// A custom syntax receiver that collects all the classes with the [Serializable] attribute 
public class SerializationSyntaxReceiver : ISyntaxReceiver 
{ 
    public List<ClassDeclarationSyntax> CandidateClasses { get; } = new List<ClassDeclarationSyntax>();

    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        // Check if the syntax node is a class declaration with the [Serializable] attribute
        if (syntaxNode is ClassDeclarationSyntax classDeclaration &&
            classDeclaration.AttributeLists.Count > 0 &&
            classDeclaration.AttributeLists.Any(
                al => al.Attributes.Any(a => a.Name.ToString() == "Serializable")))
        {
            // Add it to the candidate list
            CandidateClasses.Add(classDeclaration);
        }
    }
}
C#

Implement the Execute method to generate the extension methods for each collected class using the syntax and semantic models provided by the context parameter.

public void Execute(GeneratorExecutionContext context)
{
    // Get the compilation object that represents all user code being compiled
    var compilation = context.Compilation;


    // Get the syntax receiver that was created by our callback
    var receiver = context.SyntaxReceiver as SerializationSyntaxReceiver;
    if (receiver == null)
    {
        return;
    }

    // Loop through all the syntax trees in the compilation
    foreach (var syntaxTree in compilation.SyntaxTrees)
    {
        // Get the semantic model for the syntax tree
        var model = compilation.GetSemanticModel(syntaxTree);

        // Loop through all the class declarations
        foreach (var classDeclaration in receiver.CandidateClasses)
        {
            try
            {
                // Get the symbol for the class declaration
                var classSymbol = model.GetDeclaredSymbol(classDeclaration) as INamedTypeSymbol;

                if (classSymbol != null)
                {
                    // Generate the extension method for the ToJson method
                    var source = GenerateSource(classSymbol);

                    // Add a new source file to the compilation with a unique hint name
                    context.AddSource($"{classSymbol.Name}.ToJson.cs", SourceText.From(source, Encoding.UTF8));
                }
            }
            catch (System.Exception ex)
            {
                System.Console.WriteLine(ex);
            }
        }
    }
}
C#

This code will get the Compilation object and loop through all the syntax trees, looking for classes that were selected in the Initialize method. For each found class, it will call the GenerateSource method and will write a new text file with the generated source:

    private string GenerateSource(INamedTypeSymbol classSymbol)
    {
        // Get the name of the class
        var className = classSymbol.Name;

        // Get the properties of the class
        var properties = classSymbol.GetMembers().OfType<IPropertySymbol>();
        if (!properties.Any())
        {
            return "";
        }

        // Generate code for the ToJson method using StringBuilder
        var builder = new StringBuilder();
        builder.AppendLine($@"
using System.Text.Json;

public static class {className}Extensions
{{
    public static string ToJson(this {className} {className.ToLower()})
    {{");

        // Append code to create a new JSON object as a string using string interpolation and escaping
        builder.Append(@"        return $""{{");
        foreach (var property in properties)
        {
            builder.Append($@"\""{property.Name}\"":\""{{{className.ToLower()}.{property.Name}}}\"",");
        }
        builder.Remove(builder.Length - 1, 1); // Remove trailing comma
        builder.Append(@"}}"";");
        builder.Append(@$"
    }}
        
    public static {className} FromJson(string json)
    {{");

        // Append code to parse the JSON string as a JSON object using JsonDocument.Parse
        builder.AppendLine($@"
        using var document = JsonDocument.Parse(json);
        var root = document.RootElement;");

        // Append code to create a new instance of the class using its default constructor
        builder.AppendLine($@"
        var {className.ToLower()} = new {className}();");

        // Append code to assign each property value from the JSON object using JsonElement.GetProperty and TryGet methods
        foreach (var property in properties)
        {
            builder.AppendLine($@"
        if (root.TryGetProperty(""{property.Name}"", out var {property.Name}Element))
        {{
            {className.ToLower()}.{property.Name} = {GetConversionCode(property.Type, $"{property.Name}Element")};
        }}");
        }

        // Append code to return the created object as a result
        builder.AppendLine($@"
        return {className.ToLower()};
    }}
}}");

        // Return the generated source code as a string
        return builder.ToString();
    }

    private string GetConversionCode(ITypeSymbol type, string value)
    {
        // Generate code to convert a JsonElement value to a given type using switch expression and JsonElement.Get methods
        return type switch
        {
            INamedTypeSymbol namedType when namedType.SpecialType == SpecialType.System_String => $"{value}.GetString()",
            INamedTypeSymbol namedType when namedType.SpecialType == SpecialType.System_Int32 => $"{value}.GetInt32()",
            INamedTypeSymbol namedType when namedType.SpecialType == SpecialType.System_Double => $"{value}.GetDouble()",
            INamedTypeSymbol namedType when namedType.SpecialType == SpecialType.System_Boolean => $"{value}.GetBoolean()",
            INamedTypeSymbol namedType when namedType.SpecialType == SpecialType.System_DateTime => $"{value}.GetDateTime()",
            _ => throw new NotSupportedException($"Unsupported type: {type}")
        };
    }
C#

This code will get the properties for the classes that have the [Serializable] attribute and will generate a new static class with two methods, ToJson and FromJson, that will serialize and deserialize the class. For example, this code will generate this class for the Person class:

using System.Text.Json;

public static class PersonExtensions
{
    public static string ToJson(this Person person)
    {
        return $"{{\"FirstName\":\"{person.FirstName}\",\"LastName\":\"{person.LastName}\",\"Age\":\"{person.Age}\"}}";
    }
        
    public static Person FromJson(string json)
    {
        using var document = JsonDocument.Parse(json) ;
        var root = document.RootElement;

        var person = new Person();

        if (root.TryGetProperty("FirstName", out var FirstNameElement))
        {
            person.FirstName = FirstNameElement.GetString();
        }

        if (root.TryGetProperty("LastName", out var LastNameElement))
        {
            person.LastName = LastNameElement.GetString();
        }

        if (root.TryGetProperty("Age", out var AgeElement))
        {
            person.Age = AgeElement.GetInt32();
        }

        return person;
    }
}
C#

As you can see, the generated code is specific for the Person class and doesn't make use of reflection. To test this code, we can create a new project, JsonGeneratorTest and add the generator project as a reference:

cd ..
dotnet new console -o JsonTest
cd JsonTest
dotnet add reference ..\JsonGenerator\JsonGenerator.csproj
code .
Dos

And then change the csproj file to tell the referenced project is an analyzer:

<Project Sdk="Microsoft.NET.Sdk">

  <ItemGroup>
    <ProjectReference Include="..\JsonGenerator\JsonGenerator.csproj" 
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false"/>
  </ItemGroup>

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

The code for the tester program is:

var person = new Person
{
    FirstName = "John",
    LastName = "Doe",
    Age = 20
};
Console.WriteLine(person.ToJson());

var person1 = PersonExtensions.FromJson("{ \"FirstName\": \"Mary\", \"LastName\": \"Jane\", \"Age\": 30}");
Console.WriteLine(person1.ToJson());

[Serializable]
public class Person
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public int Age { get; set; }
}
C#

Once you run the code, you will get something like this:

As you can see, the generator has created the class and compiled it with the code and we can use it the same way as if we had written it ourselves. This saves us from writing boilerplate code and avoids the use of reflection.

The full source code for the article is at https://github.com/bsonnino/SourceGenerators

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

  • May 2025
  • December 2024
  • October 2024
  • August 2024
  • July 2024
  • June 2024
  • November 2023
  • October 2023
  • August 2023
  • July 2023
  • June 2023
  • May 2023
  • November 2022
  • October 2022
  • September 2022
  • August 2022
  • June 2022
  • April 2022
  • March 2022
  • February 2022
  • January 2022
  • July 2021
  • June 2021
  • May 2021
  • April 2021
  • March 2021
  • February 2021
  • January 2021
  • December 2020
  • October 2020
  • September 2020
  • April 2020
  • March 2020
  • January 2020
  • November 2019
  • September 2019
  • August 2019
  • July 2019
  • June 2019
  • April 2019
  • March 2019
  • February 2019
  • January 2019
  • December 2018
  • November 2018
  • October 2018
  • September 2018
  • August 2018
  • July 2018
  • June 2018
  • May 2018
  • November 2017
  • October 2017
  • September 2017
  • August 2017
  • June 2017
  • May 2017
  • March 2017
  • February 2017
  • January 2017
  • December 2016
  • November 2016
  • October 2016
  • September 2016
  • August 2016
  • July 2016
  • June 2016
  • May 2016
  • April 2016
  • March 2016
  • February 2016
  • October 2015
  • August 2013
  • May 2013
  • February 2012
  • January 2012
  • April 2011
  • March 2011
  • December 2010
  • November 2009
  • June 2009
  • April 2009
  • March 2009
  • February 2009
  • January 2009
  • December 2008
  • November 2008
  • October 2008
  • July 2008
  • March 2008
  • February 2008
  • January 2008
  • December 2007
  • November 2007
  • October 2007
  • September 2007
  • August 2007
  • July 2007
  • Development
  • English
  • Português
  • Uncategorized
  • Windows

.NET AI Algorithms asp.NET Backup C# Debugging Delphi Dependency Injection Desktop Bridge Desktop icons Entity Framework JSON Linq Mef Minimal API MVVM NTFS Open Source OpenXML OzCode PowerShell Sensors Silverlight Source Code Generators sql server Surface Dial Testing Tools TypeScript UI Unit Testing UWP Visual Studio VS Code WCF WebView2 WinAppSDK Windows Windows 10 Windows Forms Windows Phone WPF XAML Zip

  • Entries RSS
  • Comments RSS
©2025 Bruno Sonnino | Design: Newspaperly WordPress Theme
Menu
  • Home
  • About