Claude
Skills
Sign in
Back

dotnet-csharp-source-generators

Included with Lifetime
$97 forever

Creating source generators. IIncrementalGenerator, GeneratedRegex, LoggerMessage, STJ source-gen.

General

What this skill does


# dotnet-csharp-source-generators

Guidance for both **creating** and **consuming** Roslyn source generators in .NET. Creating: `IIncrementalGenerator`, syntax providers, semantic analysis, emit patterns, diagnostic reporting, testing with `CSharpGeneratorDriver`. Consuming: `[GeneratedRegex]`, `[LoggerMessage]`, System.Text.Json source generation, `[JsonSerializable]`.

Cross-references: [skill:dotnet-csharp-modern-patterns] for partial properties and related C# features, [skill:dotnet-csharp-coding-standards] for naming conventions.

---

## Creating Source Generators

### Project Setup

Source generators are shipped as analyzers targeting `netstandard2.0`.

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

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

> **Always target `netstandard2.0`.** Generators load into the compiler process, which requires this TFM for compatibility. Use `LangVersion>latest` to write modern C# in the generator itself.

### `IIncrementalGenerator` (Preferred)

Always use `IIncrementalGenerator` over the legacy `ISourceGenerator`. Incremental generators are cache-aware and only re-run when inputs change, making them significantly faster in IDE scenarios.

```csharp
[Generator]
public sealed class AutoNotifyGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Step 1: Filter syntax nodes to candidate fields
        var fieldDeclarations = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyLib.AutoNotifyAttribute",
                predicate: static (node, _) => node is FieldDeclarationSyntax,
                transform: static (ctx, _) => GetFieldInfo(ctx))
            .Where(static info => info is not null)
            .Select(static (info, _) => info!.Value);

        // Step 2: Group fields by containing type, then emit one file per type
        context.RegisterSourceOutput(fieldDeclarations.Collect(),
            static (spc, fields) => Execute(fields, spc));
    }

    private static FieldInfo? GetFieldInfo(
        GeneratorAttributeSyntaxContext context)
    {
        var fieldSymbol = context.TargetSymbol as IFieldSymbol;
        if (fieldSymbol is null)
            return null;

        var containingType = fieldSymbol.ContainingType;

        // Use fully qualified type name to handle generic and nested types
        var fullTypeName = containingType.ToDisplayString(
            SymbolDisplayFormat.FullyQualifiedFormat
                .WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted));

        return new FieldInfo(
            fieldSymbol.ContainingNamespace.IsGlobalNamespace
                ? ""
                : fieldSymbol.ContainingNamespace.ToDisplayString(),
            containingType.Name,
            fullTypeName,
            fieldSymbol.Name,
            fieldSymbol.Type.ToDisplayString());
    }

    private static void Execute(
        ImmutableArray<FieldInfo> fields,
        SourceProductionContext context)
    {
        // Group by fully qualified type name to emit one file per class
        foreach (var group in fields.GroupBy(f => f.FullTypeName))
        {
            var first = group.First();
            var ns = first.Namespace;
            var className = first.ClassName;
            var properties = new StringBuilder();

            foreach (var field in group)
            {
                var propertyName = GetPropertyName(field.FieldName);
                properties.AppendLine($$"""
                        public {{field.FieldType}} {{propertyName}}
                        {
                            get => {{field.FieldName}};
                            set
                            {
                                if (!global::System.Collections.Generic.EqualityComparer<{{field.FieldType}}>.Default.Equals({{field.FieldName}}, value))
                                {
                                    {{field.FieldName}} = value;
                                    PropertyChanged?.Invoke(this,
                                        new global::System.ComponentModel.PropertyChangedEventArgs(nameof({{propertyName}})));
                                }
                            }
                        }
                    """);
            }

            // Handle global namespace (no namespace declaration)
            var nsBlock = string.IsNullOrEmpty(ns) ? "" : $"namespace {ns};\n\n";

            var source = $$"""
                // <auto-generated/>
                #nullable enable

                {{nsBlock}}partial class {{className}}
                    : global::System.ComponentModel.INotifyPropertyChanged
                {
                    public event global::System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;

                {{properties}}
                }
                """;

            // Include namespace in hint name to avoid collisions across namespaces
            var hintPrefix = string.IsNullOrEmpty(ns) ? className : $"{ns}.{className}";
            context.AddSource($"{hintPrefix}.AutoNotify.g.cs", source);
        }
    }

    private static string GetPropertyName(string fieldName)
        => fieldName.TrimStart('_') is [var first, .. var rest]
            ? $"{char.ToUpperInvariant(first)}{rest}"
            : fieldName;
}

internal readonly record struct FieldInfo(
    string Namespace,
    string ClassName,
    string FullTypeName,
    string FieldName,
    string FieldType);
```

> **Scope note:** This example targets top-level, non-generic classes for clarity. A production generator should also handle generic type parameters (emitting matching `partial class Foo<T>` declarations) and nested types (emitting nested partial class hierarchies). Report a diagnostic for unsupported shapes rather than emitting invalid code.

### Key Pipeline Design Rules

1. **Filter early** -- Use `ForAttributeWithMetadataName` or `CreateSyntaxProvider` with a tight predicate to minimize work.
2. **Transform to simple data** -- Extract only the data you need (strings, records) in the transform step. Never pass `ISymbol` or `SyntaxNode` through the pipeline (they hold the compilation alive and break caching).
3. **Use value equality** -- Pipeline outputs are compared by value. Use `record struct` or implement `IEquatable<T>` for custom types.
4. **Emit deterministic output** -- Same inputs must produce identical source. Use `// <auto-generated/>` and `#nullable enable` headers.

### Syntax Providers

```csharp
// ForAttributeWithMetadataName -- most common, filters by attribute
var candidates = context.SyntaxProvider.ForAttributeWithMetadataName(
    "MyLib.GenerateMapperAttribute",
    predicate: static (node, _) => node is ClassDeclarationSyntax,
    transform: static (ctx, _) => /* extract info */);

// CreateSyntaxProvider -- general-purpose, any syntax predicate
var candidates = context.SyntaxProvider.CreateSyntaxProvider(
    predicate: static (node, _) => node is MethodDeclarationSyntax m
        && m.Modifiers.Any(SyntaxKind.PartialKeyword),
    transform: static (ctx, _) => /* extract info */);
```

### Diagnostic Reporting

Report errors and warnings through `SourceProductionContext` rather than throwing exceptions. To report location-specific diagnostics, include a `Location` in your pipeline data (captured from the syntax node in the transform step).

```csharp
private static readonly DiagnosticDescriptor InvalidFieldType = new(
    id: "AN001",
    title: "Invalid field type 

Related in General