C# Source Generators: How to get build information?

27/03/2023
.NETGeneratorC#

Source generators are a powerful feature introduced to C#, allowing developers to generate additional code during the compilation process automatically. They can help reduce boilerplate, improve performance, and simplify your codebase.

This blog post will introduce source generators, discuss how they work, and walk through an example of a source generator for generating build information.

What do we want to do here?

Simply having something like this:

var buildAt = BuildInformation.BuildAt; // In UTC when the Build was generated
var configuration = BuildInformation.Configuration; // Release or Debug 
var platform = BuildInformation.Platform; // Something like AnyCPU or arm64

What are Source Generators?

Source generators are a compile-time feature in C# that enables you to generate additional C# source code to be included in the compilation process. They are essentially custom Roslyn analyzers that can produce new code files, which are then compiled together with the rest of your project. Source generators can help you automate code generation for repetitive tasks, enforce coding standards, and even optimize your code at compile time.

How to Use Source Generators?

First, we have to create a new class library project that targets netstandard2.0. It has to target netstandard2.0 and not something else. The reason is here:

because the compiler must run on both the old .NET Framework and the new .NET all generators must target .NET Standard 2.0

Afterward add the following packages:

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

With these two packages, we can generate the generator. PrivateAssets means that the package is more or less a developer dependency and will not be shipped with the linked assembly. And that makes sense. The generator will create a new C# file that will be part of the linked assembly and not the generator itself, so why ship the generator?

Now we can implement the generator like this:

[Generator]
public sealed class IncrementalBuildInformationGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)

We need the GeneratorAttribute and implement the IIncrementalGenerator interface. Now we can implement the generator that creates the source code that will enable us to get the build information:

using Microsoft.CodeAnalysis;

[Generator]
public sealed class IncrementalBuildInformationGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var compilerOptions = context.CompilationProvider.Select((s, _)  => s.Options);

        context.RegisterSourceOutput(compilerOptions, static (productionContext, options) =>
        {
            var buildInformation = new BuildInformationInfo
            {
                BuildAt = DateTime.UtcNow.ToString("O"),
                Platform = options.Platform.ToString(),
                WarningLevel = options.WarningLevel,
                Configuration = options.OptimizationLevel.ToString(),
            };

            productionContext.AddSource("LinkDotNet.BuildInformation.g", GenerateBuildInformationClass(buildInformation));
        });
    }

    private static string GenerateBuildInformationClass(BuildInformationInfo buildInformation)
    {
        return $@"
using System;
using System.Globalization;
public static class BuildInformation
{{
    /// <summary>
    /// Returns the build date (UTC).
    /// </summary>
    /// <remarks>Value is: {buildInformation.BuildAt}</remarks>
    public static readonly DateTime BuildAt = DateTime.ParseExact(""{buildInformation.BuildAt}"", ""O"", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
    /// <summary>
    /// Returns the platform.
    /// </summary>
    /// <remarks>Value is: {buildInformation.Platform}</remarks>
    public const string Platform = ""{buildInformation.Platform}"";
    /// <summary>
    /// Returns the warning level.
    /// </summary>
    /// <remarks>Value is: {buildInformation.WarningLevel}</remarks>
    public const int WarningLevel = {buildInformation.WarningLevel};
    /// <summary>
    /// Returns the configuration.
    /// </summary>
    /// <remarks>Value is: {buildInformation.Configuration}</remarks>
    public const string Configuration = ""{buildInformation.Configuration}"";
}}
";
    }

    private sealed class BuildInformationInfo
    {
        public string BuildAt { get; set; } = string.Empty;
        public string Platform { get; set; } = string.Empty;
        public int WarningLevel { get; set; }
        public string Configuration { get; set; } = string.Empty;
    }
}

The provided code is an example of an incremental source generator that generates a static BuildInformation class containing build-related information, such as build date, platform, warning level, and configuration. Let's break it down step by step:

  1. The IncrementalBuildInformationGenerator class is marked with the [Generator] attribute and implements the IIncrementalGenerator interface. This informs the compiler that this class should be treated as an incremental source generator.
  2. The Initialize method is responsible for setting up the generator's dependencies and registering the output. In this example, the generator depends on the compiler options. The context.CompilationProvider is used to obtain the compilation, and the Select method is used to extract the Options property from it.
  3. The context.RegisterSourceOutput method is called to register the generator's output based on its dependencies (the compilerOptions). It accepts a delegate with two parameters: the productionContext, which allows you to add generated source code to the compilation, and the options, which contains the compiler options.
  4. The productionContext.AddSource method is called, passing a unique name for the generated file ("LinkDotNet.BuildInformation.g") and the generated source code. This adds the generated BuildInformation class to the compilation.

Usage

Now let's create a new console project to use the generator. To include the generator, you can do this:

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

As we can see, the generator is considered an analyzer and we don't reference the generator in our output - as we described a bit above! Depending on your IDE, you can directly use our class or hit the "Build" button:

Console.WriteLine($"Build at: {BuildInformation.BuildAt}");
Console.WriteLine($"Platform: {BuildInformation.Platform}");
Console.WriteLine($"Warning level: {BuildInformation.WarningLevel}");
Console.WriteLine($"Configuration: {BuildInformation.Configuration}");

Which can lead to the following output:

Build at: 2023-03-23T12:34:56.7890123Z
Platform: AnyCPU
Warning level: 4
Configuration: Debug

Pretty easy. Of course, this was an easy entrance to the generators, but it can get complicated very quickly, especially if you have to handle the whole compilation object.

Conclusion

Source generators in C# provide an innovative way to automate code generation and improve your codebase. They can be used to simplify repetitive tasks, enforce coding standards, and optimize your code at compile time. The IncrementalBuildInformationGenerator example demonstrates how to create a source generator that generates build information, making it easy to track and access important details about your project's build configuration.

Resources

1
An error has occurred. This application may no longer respond until reloaded. Reload x