C# Source Generators

Introduction

C# Source Generators made their first appearance around the release of .NET 5 and now ship as part of the .NET Compiler Platform (“Roslyn”) SDK. They allow developers to inspect user code as it is being compiled and even create new C# source files on the fly and add them to the compilation.

A source generator is a .NET Standard 2.0 assembly that is loaded by the compiler. It’s usable in environments where .NET Standard components can be loaded and run, including .NET/.NET Core, and .NET Framework 4.6.1+.

There are lots of use cases for source code generators, some common ones include:

  • Automate boilerplate/template code.
  • Shifting various runtime reflection tasks to compile-time.
  • Code analysers (styling, bugs, vulnerabilities, etc).

My interest was as a means of producing different C# implant builds based on a provided configuration. Imagine a C# implant that you want to embed an HTTP traffic profile into, or specify different post-ex behaviours.

Creating a Code Generator

To get started with your first code generator, create a new solution made of a Console Application and .NET Standard 2.0 Class Library. The library project needs to have the Microsoft.CodeAnalysis.CSharp & Microsoft.CodeAnalysis.Analyzers packages installed, and EnforceExtendedAnalyzerRules in the .csproj set to true.

<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.CSharp" Version="4.6.0" PrivateAssets="all" />
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
    </ItemGroup>

</Project>

Then add a reference to the console app, pointing at the library project. Ensure the OutputItemType is set to Analyzer and ReferenceOutputAssembly to false.

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

To create the generator functionality, create a new class with the Generator attribute and have it inherit from a generator interface, such as ISourceGenerator.

using Microsoft.CodeAnalysis;

namespace CodeGenerator;

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

    public void Execute(GeneratorExecutionContext context)
    {
        
    }
}

We don’t need to do anything in the Initialize method, so we’ll focus on Execute. Adding some new code to the compilation can be as simple as:

public void Execute(GeneratorExecutionContext context)
{
    var sourceCode = SourceText.From("""
        namespace ConsoleApp;

        public static class GeneratedCode
        {
            public static string GeneratedMessage = "Hello from Generated Code";
        }
        """, Encoding.UTF8);

    context.AddSource("GeneratedCode.g.cs", sourceCode);
}

If the code generator project is built now, the new source file will appear in the ConsoleApp project under References > Source Generators and can be used in the ConsoleApp code.

using System;

namespace ConsoleApp;

internal static class Program
{
    public static void Main(string[] args)
    {
        Console.WriteLine(GeneratedCode.GeneratedMessage);
    }
}
.\ConsoleApp.exe
Hello from Generated Code

Polymorphism

The above example obviously has limited utility. A more useful implementation could be leveraged using abstract classes and overrides. This could allow you to provide a default behaviour and override it from the code generator.

In this example, we have an abstract BaseClass and a partial ExampleClass that inherits from it.

namespace ConsoleApp;

public partial class ExampleClass : BaseClass { }

public abstract class BaseClass
{
    public virtual string GetMessage()
    {
        return "Hello World";
    }
}

If we instantiate a new instance of ExampleClass and call GetMessage we’ll see “Hello World!” (nothing special there).

using System;

namespace ConsoleApp;

internal static class Program
{
    public static void Main(string[] args)
    {
        var example = new ExampleClass();
        Console.WriteLine(example.GetMessage());
    }
}
.\ConsoleApp.exe
Hello World

To override the implementation from our source generator, we could do:

public void Execute(GeneratorExecutionContext context)
{
    var sourceCode = SourceText.From("""
        namespace ConsoleApp;

        public partial class ExampleClass
        {
            public override string GetMessage()
            {
                return "Hello from overridden code";
            }
        }
        """, Encoding.UTF8);

    context.AddSource("ExampleClass.g.cs", sourceCode);
}
.\ConsoleApp.exe
Hello from overridden code

Configuration File

It would be nice if we could pass some sort of configuration file to the code generator to dictate its behaviour. Create a JSON file in the ConsoleApp project and add the following to replicate (at least part of) an “HTTP traffic profile”.

{
  "ConnectAddress": "localhost",
  "ConnectPort": 8080,
  "Uris": [
    "/home",
    "/news"
  ]
}

Change the properties of the file in the project so that it’s an “AdditionalFile”.

<ItemGroup>
  <AdditionalFiles Include="config.json">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </AdditionalFiles>
</ItemGroup>

Then create a POCO in the code generator to represent that JSON.

public sealed class Config
{
    public string ConnectAddress { get; set; }
    public int ConnectPort { get; set; }
    public string[] Uris { get; set; }
}

Inside the Execute method, we can read the content of the JSON file and deserialize it into a Config class that we can handle nicely.

var json = context.AdditionalFiles
    .Single(t => t.Path.Contains("config.json"))
    .GetText()
    .ToString();

var config = JsonSerializer.Deserialize<Config>(json);

Then use the content to create a new source file, effectively translating the configuration into a new static class.

var sourceCode = SourceText.From($$"""
    namespace ConsoleApp;

    public static class HttpProfile
    {
        public static string ConnectAddress { get; } = "{{config.ConnectAddress}}";
        public static int ConnectPort { get; } = {{config.ConnectPort}};
        public static string[] Uris { get; } = { {{string.Join(", ", config.Uris.Select(u => $"\"{u}\""))}} };
    }
    """, Encoding.UTF8);

context.AddSource("HttpProfile.g.cs", sourceCode);

As before, we can then reference this generated code class in the ConsoleApp.

public static void Main(string[] args)
{
    Console.WriteLine($"Connect Address : {HttpProfile.ConnectAddress}");
    Console.WriteLine($"Connect Port    : {HttpProfile.ConnectPort}");
    Console.WriteLine($"URIs            : {string.Join(", ", HttpProfile.Uris)}");
}
.\ConsoleApp.exe
Connect Address : localhost
Connect Port    : 8080
URIs            : /home, /news

Closing

C# source generators open a lot of interesting potential for producing customised builds. I’ve barely scratched the surface here. Please let me know if you have any source generator tricks that you’d like to share.

Related posts

ANYSIZE_ARRAY in C#

There are multiple structures in Windows that contain fixed sized arrays. The instance...

SafeHandle vs IntPtr

C# is a popular language in both the commercial space (think ASP.NET Core, MVC,...

Latest posts

ANYSIZE_ARRAY in C#

There are multiple structures in Windows that contain fixed sized arrays. The instance...

SafeHandle vs IntPtr

C# is a popular language in both the commercial space (think ASP.NET Core, MVC,...