Einen Source Generator selbst schreiben
- Matt
Für den JSON-Quellgenerator und die Regex- und Logging-Generatoren reicht ein Attribut aus dem Framework. Für ein DTO-Projekt mit sich wiederholenden ToString()-Implementierungen gibt es aber keinen fertigen – also selbst einen schreiben.
Projektaufbau
Ein Source Generator ist ein eigenes Klassenbibliotheks-Projekt, referenziert als Analyzer. Es muss netstandard2.0 als Zielframework verwenden, weil der Compiler selbst nur diese Version lädt, egal mit welchem .NET das Zielprojekt läuft:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" PrivateAssets="all" />
</ItemGroup>
</Project>
EnforceExtendedAnalyzerRules meldet schon beim Übersetzen, wenn der Generator gegen Regeln verstößt, die sonst erst zur Laufzeit als IDE-Ruckler auffallen würden – etwa veränderlichen Zustand zwischen zwei Pipeline-Durchläufen.
Die Pipeline
IIncrementalGenerator ist die aktuelle Schnittstelle. Die ältere ISourceGenerator läuft bei jeder Tastatureingabe komplett neu; IIncrementalGenerator cacht Zwischenschritte:
[Generator(LanguageNames.CSharp)]
public class AutoToStringGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var klassen = context.SyntaxProvider.ForAttributeWithMetadataName(
"MeineLib.AutoToStringAttribute",
predicate: (node, _) => node is ClassDeclarationSyntax,
transform: (ctx, _) => (INamedTypeSymbol)ctx.TargetSymbol);
context.RegisterSourceOutput(klassen, (spc, typ) =>
{
var felder = string.Join(", ",
typ.GetMembers().OfType<IPropertySymbol>().Select(p => $"{p.Name}={{{p.Name}}}"));
spc.AddSource($"{typ.Name}.g.cs", $$"""
namespace {{typ.ContainingNamespace}};
partial class {{typ.Name}} { public override string ToString() => $"{{felder}}"; }
""");
});
}
}
ForAttributeWithMetadataName filtert intern nach dem Attributnamen, bevor ein Callback läuft. Ein selbstgebauter Syntax-Walker über den kompletten Baum ist bei jedem Tastendruck spürbar langsamer.
Einbinden im Zielprojekt
Der Generator wird nicht als normale Bibliothek referenziert, sonst landet er als Laufzeitabhängigkeit statt als Analyzer:
<ProjectReference Include="..\MeineLib.Generators\MeineLib.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
Ohne OutputItemType="Analyzer" kompiliert das Projekt trotzdem, das Attribut bleibt aber wirkungslos. Das war bei mir die erste Stunde Fehlersuche.
Der Haken
Debuggen ist umständlich: Der Generator läuft im Prozess des Compilers, nicht der Anwendung, ein Breakpoint im Testprojekt hilft also nichts. Um den erzeugten Code überhaupt zu sehen, hilft <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> im Zielprojekt, das schreibt ihn nach obj statt ihn nur im Speicher des Compilers zu halten.
Und die Inkrementalität, die den Aufwand rechtfertigt, funktioniert nur, wenn jeder Zwischenwert in der Pipeline über Equals vergleichbar ist. Symbole aus der Compilation sind das nicht zuverlässig, weshalb man in der Praxis früh auf eigene, unveränderliche Zwischentypen umsteigt. Wer das überspringt, bekommt einen Generator, der bei jeder Änderung irgendwo im Projekt komplett neu läuft, obwohl die Schnittstelle das Gegenteil verspricht.
Die ausführliche Referenz mit weiteren Pipeline-Rezepten steht im Source Generator Cookbook, die Grundlagen in der Übersicht auf Microsoft Learn.