Writing simple C# source code generator with Fluent API

Introduction

Usually in .NET world when you want to write generic code you use generic method or classes. But sometimes it is still not enough when you want to automate some things. I.e. you want to dynamically register in DI all the classes that implements some interface. Probably, in this case, you would do something like that:

var implementations = typeof(MyClass).Assembly.Types
   .Where(t => t.GetInterfaces().Any(i => i == typeof(IMyInterface))
foreach(var implementation in implementations)
{
   services.AddScoped<IMyInterface>(implementation);
}

And it would be completely fine. Even if Reflection is pretty slow, your application would not be getting significantly slower because of that. Certainly you would not notice the slow down.

For some other similar requirements some people used Fody or similar tool, that alters your assembly based on specified weavers. It works, though I always found if very confusing during debugging (because source code is no longer valid).

But we are not here to do the ‘ok’ job. We want better, more efficient code, that is generic but still close to native code. We strife for greatness!

Ok, maybe this is a bit too much, but following new .NET optimizations, we want to use new tools at our disposal to write simple but yet, efficient code.

We can do that by meta programming in C# and .NET using Roslyn Source Generators.

Source code generator

Writing your own source code generator is not that easy as it could be. Certainly it is a bit mode advanced topic and Microsoft is not putting as much money (and attention) into it as into new and groovy features that supposed to get you hook up into C#. But it should not stop you because it is also really cool seeing your source code generator generating new code on the fly, while you are making changes in your IDE. Seeing an error becoming valid code because code was generated in the background based on logic you coded is really great feeling!

In above video you can see my IDE, Rider, generating a code, class Hi with method Log generated on the fly based on new text file that is added to the project. First new Hi().Log() method call in the Program.cs is invalid, but when new txt file is added, source code generator generates new class, Program.cs becomes valid and when application is running, new line is written to the console. Pretty cool!

This may sound a bit complicated, and to some extent it is, but it probably won’t be most complicated thing you did or will do.

Creating new project

As with many C# examples, also in this, you have to create a new project.

This new project to be valid source code generator, it have to target .NET Standard 2.0. It is pretty simple to do with dropdown selection in your IDE or you can just change .csproj file directly:

<TargetFramework>netstandard2.0</TargetFramework>

Then mark your project as Roslyn component. You need to add following properties to <PropertyGroup> section in your project file:

<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>

After that install one package that helps you a bit with Roslyn APIs. For me personally they were a bit hard to understand when I first started working with them:

<PackageReference Include="HamsterWheel.FluentCodeGenerators" Version="0.4.0" PrivateAssets="all" />

You need to add also importing all of code generator Nuget packages to the output directory of your generator. Without it it will throw errors like below:

CSC : warning CS8784: Generator 'DemoIncrementalGenerator' failed to initialize. It will not contribute to the output and compilation errors may occur as a result. Exception was of type 'FileNotFoundException' with message 'Could not load file or assembly 'HamsterWheel.FluentCodeGenerators, Version=0.4.1.0, Culture=neutral, PublicKeyToken=null'. The system cannot find the file specified. [/builds/hamster-wheel/fluentcodegenerators/demo/HamsterWheel.FluentCodeGenerators.Demo.Use/HamsterWheel.FluentCodeGenerators.Demo.Use.csproj]

To fix that add following tags to your project file:

<Content Include="$(PKGHamsterWheel_FluentCodeGenerators)\lib\netstandard2.0\*.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="False" />
<Content Include="$(PKGHamsterWheel_FluentCodeGenerators_Abstractions)\lib\netstandard2.0\*.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="False" />

and also change the package reference tags:

<ItemGroup>
  <PackageReference Include="HamsterWheel.FluentCodeGenerators" Version="0.4.1" GeneratePathProperty="true" />
  <PackageReference Include="HamsterWheel.FluentCodeGenerators.Abstractions" Version="0.4.1" GeneratePathProperty="true" />
</ItemGroup>

This is enough to work on the Nuget package but if you want to develop your source code generator, it is much easier to have it in the same solution you want generate code for. To do this you need to do one additional change:

<PropertyGroup>
  <GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
</PropertyGroup>

<Target Name="GetDependencyTargetPaths">
  <ItemGroup>
     <TargetPathWithTargetPlatformMoniker Include="$(PKGHamsterWheel_FluentCodeGenerators_Abstractions)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false"/>
     <TargetPathWithTargetPlatformMoniker Include="$(PKGHamsterWheel_FluentCodeGenerators)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false"/>
  </ItemGroup>
</Target>

Above directives will make sure that all necessary packages, that are referenced by your source code generator project, are copied to appropriate directory from where your IDE and dotnet build are using your code generator .dll file. Without it it would be another FileNotFoundException during initialization.

After that your project should look similar too below:

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

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>latestmajor</LangVersion>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>

        <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
        <IsRoslynComponent>true</IsRoslynComponent>
        <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
        <IsPackable>false</IsPackable>
    </PropertyGroup>
    
    <ItemGroup>
        <PackageReference Include="HamsterWheel.FluentCodeGenerators" Version="0.4.1" GeneratePathProperty="true" PrivateAssets="all" />
        <PackageReference Include="HamsterWheel.FluentCodeGenerators.Abstractions" Version="0.4.1" GeneratePathProperty="true" PrivateAssets="all" />
    </ItemGroup>

    <PropertyGroup>
        <GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
    </PropertyGroup>

    <Target Name="GetDependencyTargetPaths">
        <ItemGroup>
            <TargetPathWithTargetPlatformMoniker Include="$(PKGHamsterWheel_FluentCodeGenerators_Abstractions)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false"/>
            <TargetPathWithTargetPlatformMoniker Include="$(PKGHamsterWheel_FluentCodeGenerators)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false"/>
        </ItemGroup>
    </Target>

</Project>

Writing the generator

With project fully prepared we can jump to writing actual generator. First we need to add incremental generator class.

Lets start with following:

[Generator(LanguageNames.CSharp)]
public class DemoSolutionIncrementalGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
    }
}

This is bare minimum code for incremental Roslyn source code generator. But as minimal starting point it does not do anything, so it is not quite useful. Yet.

Lets add following lines into Initializer method:

context.RegisterPostInitializationOutput(c =>
{
    c.AddSource("DummyFile.g.cs", """
                                  public class MyDummyGeneratedClassFile
                                  {
                                  }
                                  """);
});

This will instruct generator to emit single file called Dummy.g.cs with following content:

public class MyDummyGeneratedClassFile
{
}

This is just single, empty public class. Not very helpful, but as a starting demo is just fine.

This is good for very simple types that are to be added to the project before actual incremental generator is being used. In example if you coded you generator to enhance definition of user classes decorated by a specific attribute the flow would be:

  1. Your generator package is installed
  2. Incremental generator runs and RegisterPostInitializationOutput is executed.
  3. This will add generated attribute to user project
  4. User is coding new class using this attribute
  5. Your incremental generator is looking at source code for this specific attribute
  6. If any was found incremental source code generation is executed

This is how Regex code generators works with GeneratedRegex attribute.

Steps 5 and 6 require to use Roslyn API for obtaining IValueProvider. For example if we want to add new class for each of text files added to the project, like in demo above, we should add few line of code:

var additionalFilesProvider = context.AdditionalTextsProvider
            .Where(AdditionalTextPredicates.FileNameExtensionIs(".txt"))
            .Select(AdditionalTextSelectors.FileNamePathAndContent)
            .Collect();

This will instruct Roslyn to look for all the additional text files. Additional text is another type of file linked to the C# project via <AdditionalFiles Include="someFile.txt"/> project directive. Then Roslyn is looking in that collection for files that ends with txt extension. Then selects name of the file and its content. After that it is collected into single collection and fed to your source code generator.

But this is just a provider. Actual implementation of the class that logs additional file content to the console will have the template like below:

public class {{ File Name }}
{
   public void Log()
   {
       Console.WriteLine("{{ file content }}");
   }
}

Above template can be generated within code generator:

context.RegisterSourceOutput(additionalFilesProvider,
    (pc, files) =>
    {
        foreach (var file in files)
        {
            pc.AddSource(Path.GetFileNameWithoutExtension(file.FileName), $$"""
                                                                          public class {{Path.GetFileNameWithoutExtension(file.FileName)}}
                                                                          {
                                                                              public void Log()
                                                                              {
                                                                                  Console.WriteLine({{file.Content.TripleQuote()}});
                                                                              }
                                                                          }
                                                                          """);
        }
    });

Lets brake it down into steps:

  • It iterates through every additional file
    • for each one registers new generator output
    • each output is named the same as additional file is named
    • each output contains class that is named the same as additional file
    • each class contains Log() method that push additional file content to the console

This is totally fine for simple classes and it will work for them. Things starts to brake down a bit:

  • when you have to share logic between different classes.
  • when you need to make sure all used types are imported with using statement
  • when you need to balance multiple levels of different parenthesis
  • when you need to make sure everything is properly indented
  • when you need to make sure parameter names match, all ; are added and all returns are in proper places

When it is true, you have giant spaghetti of strings, interpolated strings, imports and methods calls.

It is getting even more complicated, when you have few dozens of files connected to the one generator logic (in example Hamster Wheel Source Generator project contains over 300 files!). Then you have to take into account that different logic can run in different places in different generated classes. Taking care of just the idea of type you are generating without taking care of specific C# syntax it will be using is much easier to wrap your head around.

This is where HamsterWheel.FluentCodeGenerator library is coming in. It helps with all of that and more.

In example above generator code can be rewritten as:

context.WithClass(Path.GetFileNameWithoutExtension(file.FileName),
    c => c.WithMethod("Log",
            m => m.WithBody(b => b.Append($"Console.WriteLine({file.Content.TripleQuote()});")))
);

This generator will emit very similar code:

[GeneratedCode("HamsterWheel.FluentCodeGenerators", "Version=0.4.2.0")]
public class Hi
{
    public void Log()
    {
        Console.WriteLine("""
        Hi!
        """);
    }
}

It adds GeneratedCode attribute to each type it generates so other parts of the system knows it was automatically generated code. Also it have better indentation in triple quoted strings.

Maybe it does not seems to be much but this package have much more features. My favorite is automated usings management. I can count how many times during development of mine code generators I had an error when some type was not imported. This is something your rarely keep count of when writing production code yourself. Usually it is done automatically by your IDE – you just write few letters and choose correct type from drop-down of IDE hints, you IDE takes over the rest. When you do something similar inside FluentCodeGenerators fluent API, it does something very similar. In example following code:

var ipAddressType = typeof(IPAdress);
classContext.WithProp<Type>("MyType", p => p.MakeComputed().WithExpressionBody(b => b.Append($"typeof({ipAddressType})")))

Generator will automatically add appropriate namespace using statement:

using System.CodeDom.Compiler;
using System.Net; //<-- this namespace was added automatically

[GeneratedCode("HamsterWheel.FluentCodeGenerators", "Version=0.4.1.0")]
public class MyClass
{
    public Type MyType => typeof(IPAddress);
}

This is done by having InterpolatedStringHandler being used instead of regular string. This way you can handle specific chunks of it and act differently when chunk is actual type.

Summary

Writing your own C# source code generator may look quite complicated at the beginning, when you are trying to figure it out. APIs are not very friendly (at least to me). When you try to find relevant docs (code comments in those APIs basically are not existent), you find out that MSDN docs about Roslyn code generators are lacking a bit in the usual quality.

But when you actually get a hand of it and won’t be scared by bugs in IDE or compiler pipeline – the end result is feels great! Especially with Fluent API.

Leave a Reply

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

Solve : *
15 + 16 =