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.