One exciting feature of the upcoming .NET 5 are Source Generators.
Source Generators as the name suggests generate C# source code as part of the compilation process. Code generation is not a new concept in Visual Studio and .NET – for example T4 templates have been around for a while now and enable you to programmatically generate/transform content that can be compiled. There are also techniques such as IL Weaving that tools such as Fody use to manipulate the assembly that is produced from the compilation process.
Source Generators essentially enable you to add new code dynamically as part of the build process, for example adding new classes based on the hand written code in the project.
One thing to note is that Source Generators are designed to add additional generated code and not modify the code you have already written.
Source Generators can examine the existing code you have written and make decisions about what new code to generate, they can also access other files to determine what to generate.
When using Source Generators the sequence looks like this: Begin Compilation –> Any Source Generators Being Used? –> Yes –> Analyse Source Code –> Generate New Source Code –> Add Generated Source Code to Compilation –> Compile Hand-Written and Generated Source Into Output Assembly.
Creating a Simple C# Source Generator
Step 1: Creating the Source Generator
The first step is to actually define the Source Generator, this is done by creating a separate project and once it’s created, referencing it in the project you want to add generated source to.
First off you will need Visual Studio Preview and .NET 5 Preview installed.
Once installed, open VS Preview and create a new C# .NET Standard 2.0 Class Library Project project called “CheeseSourceGenerator”.
Once the project is created, you’ll need to modify the project file by double clicking on it. Source Generators are currently in preview so we can expect better tooling support in the final versions. Change the project file to the following:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<PropertyGroup>
<RestoreAdditionalProjectSources>https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet5/nuget/v3/index.json ;$(RestoreAdditionalProjectSources)</RestoreAdditionalProjectSources>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.6.0-3.20207.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.0.0-beta2.final" PrivateAssets="all" />
</ItemGroup>
</Project>
Save the project file and build the project to check there are no errors.
The next thing to do is to actually define a Source Generator. To do this add a new class to hold the generator called “Generator” and implement the ISourceGenerator interface and decorate the class with the [Generator] attribute – both of these are from the Microsoft.CodeAnalysis namespace:
using System;
using Microsoft.CodeAnalysis;
namespace CheeseSourceGenerator
{
[Generator]
public class Generator : ISourceGenerator
{
public void Execute(SourceGeneratorContext context)
{
throw new NotImplementedException();
}
public void Initialize(InitializationContext context)
{
throw new NotImplementedException();
}
}
}
The Execute method is where the actual source code generation takes place and the Initialize method allows for some more complex scenarios. In this simple example we’ll just add code to the Generate method and leave the method empty.
In this simple example we’ll just add a new class to the compilation – in this simple example there is no logic involved in the generation:
using System;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace CheeseSourceGenerator
{
[Generator]
public class Generator : ISourceGenerator
{
public void Execute(SourceGeneratorContext context)
{
const string source = @"
namespace GeneratedCheese
{
public class CheeseChooser
{
public string BestCheeseForPasta => ""Parmigiano-Reggiano"";
public string BestCheeseForBakedPotato => ""Mature Cheddar"";
}
}
";
const string desiredFileName = "CheeseChooser.cs";
SourceText sourceText = SourceText.From(source, Encoding.UTF8); // If no encoding specified then SourceText is not debugable
// Add the "generated" source to the compilation
context.AddSource(desiredFileName, sourceText);
}
public void Initialize(InitializationContext context)
{
// Advanced usage
}
}
}
Notice in the preceding code that the SourceGeneratorContext passed to the Execute method is the object that allows us to add the source to the compilation.
Build the project. At this point no source code generation has taken place, we’ve just compiled the generator into an assembly.
Step 2: Register the C# Source Generator in a Project
Add a new .NET Core Console project to the solution called “CheeseConsole”.
Once created add a project reference to the CheeseSourceGenerator project. This will allow the console app to generate source code as part of its compilation.
The project file will now look like:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Cheese\CheeseSourceGenerator.csproj" />
</ItemGroup>
</Project>
To actually opt-in to the code generation, the CheeseConsole project file needs to modified to add <LangVersion>preview</LangVersion> and change the reference to the generator project to be an analyser reference:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Cheese\CheeseSourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"/>
</ItemGroup>
</Project>
If you build everything now you should see no errors.
Step 3: Use the Generated Code
In the console app Program.cs add a using directive to the namespace that was used in the source code string, namely using GeneratedCheese;
In the Main method we can now create an instance of a CheeseChooser and make use of it. Add the following code and notice that you get Intellisense support when referencing the BestCheeseForPasta and BestCheeseForBakedPotato properties.
The Program.cs file should look like:
using System;
using GeneratedCheese;
namespace CheeseConsole
{
class Program
{
static void Main(string[] args)
{
var cheeseChooser = new CheeseChooser();
Console.WriteLine($"The best cheese for pasta is: {cheeseChooser.BestCheeseForPasta}");
Console.WriteLine($"The best cheese for potato is: {cheeseChooser.BestCheeseForBakedPotato}");
Console.ReadLine();
}
}
}
If you run the console app you should see the following:
The best cheese for pasta is: Parmigiano-Reggiano
The best cheese for potato is: Mature Cheddar
This example is very simplistic but there are a number of other use cases that I’ll cover in future posts such as:
- Augmenting existing code
- Auto-implementing boilerplate code (such as INotifyPropertyChanged)
- Generation from (non C#) external file
- Generation from database contents
- Serialization without reflection
- etc.
SHARE: