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.