AgentSkillsCN

generators-cheatsheet

适用场景:构建增量式源代码生成器,在编译时输出 C# 代码,包括语法提供者、语义模型查询、基于属性的生成,以及诊断报告。不适用于:IL 织入(应使用 Fody)、T4 文本模板、使用 Reflection.Emit 进行运行时代码生成,或仅报告诊断而不生成代码的分析器。

SKILL.md
--- frontmatter
name: generators-cheatsheet
description: >
  USE FOR: Building incremental source generators that emit C# code at compile time, including
  syntax providers, semantic model queries, attribute-driven generation, and diagnostic reporting.
  DO NOT USE FOR: IL weaving (use Fody), T4 text templates, runtime code generation with
  Reflection.Emit, or analyzers that only report diagnostics without generating code.
license: MIT
metadata:
  displayName: Source Generators Cheatsheet
  author: "Tyler-R-Kendrick"
  version: "1.0.0"
compatibility:
  - claude
  - copilot
  - cursor

Incremental Source Generators Cheatsheet

Overview

Incremental source generators run during compilation and emit additional C# source files into the compilation pipeline. They replaced the original ISourceGenerator API starting with .NET 6 / Roslyn 4.0. The incremental API (IIncrementalGenerator) uses a pipeline model where each stage produces values that are compared for equality, allowing the compiler to skip regeneration when inputs have not changed. This dramatically improves IDE responsiveness and build performance compared to the original API.

Source generators are packaged as analyzers in NuGet packages and loaded by the compiler. They cannot modify existing source code -- they can only add new files to the compilation.

Project Setup

A source generator project targets netstandard2.0 and references the Roslyn compiler APIs.

xml
<!-- MyGenerators.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4"
                      PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0"
                      PrivateAssets="all" />
  </ItemGroup>
</Project>

Consuming projects reference the generator as an analyzer:

xml
<ItemGroup>
  <ProjectReference Include="..\MyGenerators\MyGenerators.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>

Minimal Incremental Generator

The simplest generator registers a post-initialization source output that emits a static file.

csharp
using Microsoft.CodeAnalysis;

namespace MyGenerators;

[Generator]
public class HelloWorldGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        context.RegisterPostInitializationOutput(ctx =>
        {
            ctx.AddSource("HelloWorld.g.cs", """
                // <auto-generated/>
                namespace Generated;

                public static class HelloWorld
                {
                    public static string Greeting => "Hello from source generator!";
                }
                """);
        });
    }
}

Attribute-Driven Generator

Most generators use a marker attribute to identify types that should trigger generation. The attribute is emitted via RegisterPostInitializationOutput so it is available to user code at compile time.

csharp
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace MyGenerators;

[Generator]
public class AutoToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Step 1: Emit the marker attribute
        context.RegisterPostInitializationOutput(ctx =>
        {
            ctx.AddSource("AutoToStringAttribute.g.cs", """
                // <auto-generated/>
                namespace MyGenerators;

                [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)]
                public sealed class AutoToStringAttribute : System.Attribute { }
                """);
        });

        // Step 2: Create a syntax provider that filters for types with the attribute
        IncrementalValuesProvider<ClassInfo> classProvider = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                fullyQualifiedMetadataName: "MyGenerators.AutoToStringAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax or RecordDeclarationSyntax,
                transform: static (ctx, _) => GetClassInfo(ctx))
            .Where(static info => info is not null)!;

        // Step 3: Register the source output
        context.RegisterSourceOutput(classProvider, static (spc, classInfo) =>
        {
            string source = GenerateToString(classInfo);
            spc.AddSource($"{classInfo.Name}_ToString.g.cs", SourceText.From(source, Encoding.UTF8));
        });
    }

    private static ClassInfo? GetClassInfo(GeneratorAttributeSyntaxContext context)
    {
        if (context.TargetSymbol is not INamedTypeSymbol typeSymbol)
            return null;

        var properties = typeSymbol.GetMembers()
            .OfType<IPropertySymbol>()
            .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
            .Select(p => p.Name)
            .ToImmutableArray();

        return new ClassInfo(
            Namespace: typeSymbol.ContainingNamespace.ToDisplayString(),
            Name: typeSymbol.Name,
            Properties: properties);
    }

    private static string GenerateToString(ClassInfo info)
    {
        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated/>");
        sb.AppendLine($"namespace {info.Namespace};");
        sb.AppendLine();
        sb.AppendLine($"partial class {info.Name}");
        sb.AppendLine("{");
        sb.AppendLine("    public override string ToString()");
        sb.AppendLine("    {");
        sb.Append("        return $\"");
        sb.Append(info.Name);
        sb.Append(" {{ ");
        for (int i = 0; i < info.Properties.Length; i++)
        {
            if (i > 0) sb.Append(", ");
            sb.Append($"{info.Properties[i]} = {{{info.Properties[i]}}}");
        }
        sb.AppendLine(" }}\";");
        sb.AppendLine("    }");
        sb.AppendLine("}");
        return sb.ToString();
    }
}

internal record ClassInfo(string Namespace, string Name, ImmutableArray<string> Properties);

Usage in consuming code:

csharp
using MyGenerators;

namespace MyApp.Models;

[AutoToString]
public partial class Customer
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
}

// Generated ToString() output: "Customer { Id = 42, Name = Alice, Email = alice@example.com }"

Pipeline Operators

The incremental generator API provides operators for combining, collecting, and transforming values.

csharp
using System.Collections.Immutable;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace MyGenerators;

[Generator]
public class RegistrationGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Collect all classes implementing a specific interface
        IncrementalValuesProvider<string> serviceTypes = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (node, _) => node is ClassDeclarationSyntax { BaseList: not null },
                transform: static (ctx, ct) => GetServiceTypeName(ctx, ct))
            .Where(static name => name is not null)!;

        // Collect into a single array for batch output
        IncrementalValueProvider<ImmutableArray<string>> collected =
            serviceTypes.Collect();

        // Combine with compilation info
        IncrementalValueProvider<(Compilation Compilation, ImmutableArray<string> Services)> combined =
            context.CompilationProvider.Combine(collected);

        // Register output using the combined value
        context.RegisterSourceOutput(combined, static (spc, pair) =>
        {
            var (compilation, services) = pair;
            string assemblyName = compilation.AssemblyName ?? "UnknownAssembly";
            // Generate registration code using 'services' and 'assemblyName'
        });
    }

    private static string? GetServiceTypeName(GeneratorSyntaxContext context, CancellationToken ct)
    {
        var classDecl = (ClassDeclarationSyntax)context.Node;
        var symbol = context.SemanticModel.GetDeclaredSymbol(classDecl, ct);
        if (symbol is null) return null;

        foreach (var iface in symbol.AllInterfaces)
        {
            if (iface.ToDisplayString() == "MyApp.IService")
                return symbol.ToDisplayString();
        }
        return null;
    }
}

Pipeline Operator Reference

OperatorInputOutputPurpose
ForAttributeWithMetadataNameSyntax nodesAttributed symbolsFilter types by attribute FQN
CreateSyntaxProviderAll syntax nodesTransformed valuesCustom predicate + transform
WhereIncrementalValuesProviderFiltered providerRemove null or unwanted values
SelectIncrementalValuesProviderTransformed providerMap values to new shape
CollectIncrementalValuesProviderIncrementalValueProviderAggregate all values into ImmutableArray
CombineTwo providersTuple providerJoin two providers into paired values
WithComparerAny providerSame providerCustom equality for caching

Reporting Diagnostics

Generators can report diagnostics to surface errors and warnings in the IDE and build output.

csharp
using Microsoft.CodeAnalysis;

namespace MyGenerators;

public static class DiagnosticDescriptors
{
    public static readonly DiagnosticDescriptor MustBePartial = new(
        id: "MYGEN001",
        title: "Type must be partial",
        messageFormat: "Type '{0}' must be declared as partial to use [AutoToString]",
        category: "MyGenerators",
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor NoPublicProperties = new(
        id: "MYGEN002",
        title: "No public properties found",
        messageFormat: "Type '{0}' has no public properties for ToString generation",
        category: "MyGenerators",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true);
}

// In the generator's RegisterSourceOutput callback:
// spc.ReportDiagnostic(Diagnostic.Create(
//     DiagnosticDescriptors.MustBePartial,
//     classDecl.Identifier.GetLocation(),
//     classDecl.Identifier.Text));

Unit Testing Generators

Use CSharpGeneratorDriver to test generators in isolation.

csharp
using System.Reflection;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace MyGenerators.Tests;

[TestClass]
public class AutoToStringGeneratorTests
{
    [TestMethod]
    public void Generator_Produces_ToString_For_Decorated_Class()
    {
        // Arrange
        string source = """
            using MyGenerators;
            namespace TestApp;

            [AutoToString]
            public partial class Product
            {
                public int Id { get; set; }
                public string Name { get; set; }
            }
            """;

        var syntaxTree = CSharpSyntaxTree.ParseText(source);
        var references = new[]
        {
            MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
            MetadataReference.CreateFromFile(
                Assembly.Load("System.Runtime").Location)
        };

        var compilation = CSharpCompilation.Create("TestAssembly",
            new[] { syntaxTree },
            references,
            new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

        var generator = new AutoToStringGenerator();

        // Act
        GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
        driver = driver.RunGeneratorsAndUpdateCompilation(
            compilation, out var outputCompilation, out var diagnostics);

        // Assert
        var result = driver.GetRunResult();
        Assert.AreEqual(0, diagnostics.Length);
        Assert.IsTrue(result.GeneratedTrees.Length > 0);

        string generatedSource = result.GeneratedTrees
            .First(t => t.FilePath.Contains("Product_ToString"))
            .GetText()
            .ToString();

        Assert.IsTrue(generatedSource.Contains("public override string ToString()"));
    }
}

Best Practices

  1. Always use IIncrementalGenerator instead of the legacy ISourceGenerator API because the incremental pipeline caches intermediate results and only regenerates when inputs change, preventing IDE lag during typing.

  2. Make all predicate and transform lambdas static to avoid closure allocations that create new delegate instances on every invocation; the compiler will error with EnforceExtendedAnalyzerRules if closures are detected.

  3. Use ForAttributeWithMetadataName instead of CreateSyntaxProvider when filtering by attribute because the compiler provides an optimized fast path that skips semantic model queries for files without the target attribute.

  4. Return value types or records with value equality from transform steps so that the incremental pipeline's equality comparison can correctly detect unchanged inputs and skip regeneration.

  5. Emit the marker attribute via RegisterPostInitializationOutput so that user code can reference the attribute without a separate shared project; this source is injected before the main compilation and is always available.

  6. Target netstandard2.0 in the generator project because the Roslyn compiler host loads analyzers and generators in a netstandard2.0 context regardless of the consuming project's target framework.

  7. Prefix all generated files with // <auto-generated/> and use the .g.cs suffix to signal to editors, analyzers, and code-coverage tools that the file is machine-generated and should be excluded from style checks.

  8. Report actionable diagnostics with unique IDs (e.g., MYGEN001) and clear message formats that include the offending symbol name so developers can locate and fix the issue without reading generator source code.

  9. Write unit tests using CSharpGeneratorDriver that assert on both generated source content and diagnostics to prevent regressions; test edge cases like missing partial modifier, empty classes, and nested types.

  10. Avoid accessing the file system, network, or mutable static state from within a generator because generators run in the compiler process and must be deterministic, thread-safe, and free of side effects.