skills/rudironsoni/dotnet-harness-plugin/dotnet-csharp-source-generators

dotnet-csharp-source-generators

SKILL.md

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].

Scope

  • IIncrementalGenerator authoring and syntax providers
  • Consuming built-in generators (GeneratedRegex, LoggerMessage, STJ)
  • Diagnostic reporting and testing with CSharpGeneratorDriver
  • NuGet packaging for analyzer/generator assemblies

Out of scope

  • Roslyn analyzers and code fix providers -- see [skill:dotnet-roslyn-analyzers]
  • Modern C# language features -- see [skill:dotnet-csharp-modern-patterns]
  • Naming conventions -- see [skill:dotnet-csharp-coding-standards]

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.


<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>

```csharp

> **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);

```text

> **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 */);

```text

### 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 for AutoNotify",
    messageFormat: "Field '{0}' must be a non-pointer type",
    category: "AutoNotify",
    defaultSeverity: DiagnosticSeverity.Error,
    isEnabledByDefault: true);

// In the transform step, capture location:
var location = context.TargetNode.GetLocation();

// In the Execute method, report with location:
context.ReportDiagnostic(Diagnostic.Create(
    InvalidFieldType,
    location,       // captured from syntax node, not from projected data
    fieldName));

```text

> **Note:** `Location` is not value-equatable, so including it in your pipeline record breaks incremental caching. A
> common pattern is to carry it as a separate field that you exclude from equality, or report diagnostics in a
> `CreateSyntaxProvider` step before projecting to value types.

### Emit Patterns

```csharp

// Prefer raw string literals for templates (C# 11+, in the generator project)
var source = $$"""
    // <auto-generated/>
    #nullable enable

    namespace {{ns}};

    partial class {{className}}
    {
        {{generatedMembers}}
    }
    """;

context.AddSource($"{className}.g.cs", source);

```csharp

**File naming convention:** `{TypeName}.{Feature}.g.cs` -- the `.g.cs` suffix signals generated code and is excluded by
many linters.

### Post-Init Output (Static Source)

Use `RegisterPostInitializationOutput` for marker attributes and helper types that do not depend on user code:

```csharp

context.RegisterPostInitializationOutput(static ctx =>
{
    ctx.AddSource("AutoNotifyAttribute.g.cs", """
        // <auto-generated/>
        namespace MyLib;

        [System.AttributeUsage(System.AttributeTargets.Field)]
        internal sealed class AutoNotifyAttribute : System.Attribute { }
        """);
});

```text

---

## Testing Source Generators

Use `CSharpGeneratorDriver` to run generators in-memory and verify output.

```csharp

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;

[Fact]
public void Generator_ProducesExpectedOutput()
{
    // Arrange
    var source = """
        using MyLib;

        namespace TestApp;

        public partial class ViewModel
        {
            [AutoNotify]
            private string _name = "";
        }
        """;

    var syntaxTree = CSharpSyntaxTree.ParseText(source);
    var references = AppDomain.CurrentDomain.GetAssemblies()
        .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
        .Select(a => MetadataReference.CreateFromFile(a.Location))
        .Cast<MetadataReference>()
        .ToList();

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

    var generator = new AutoNotifyGenerator();

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

    // Assert
    Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));

    var runResult = driver.GetRunResult();
    Assert.Single(runResult.GeneratedTrees);

    var generatedSource = runResult.GeneratedTrees[0].GetText().ToString();
    Assert.Contains("public string Name", generatedSource);
}

```text

### Snapshot Testing (Verify)

For more robust testing, use the [Verify.SourceGenerators](https://github.com/VerifyTests/Verify.SourceGenerators)
package to snapshot-test generated output:

```csharp

[Fact]
public Task Generator_SnapshotTest()
{
    var source = """
        using MyLib;
        namespace TestApp;
        public partial class ViewModel
        {
            [AutoNotify]
            private string _name = "";
        }
        """;

    return TestHelper.Verify(source);
}

```text

---

## Consuming Built-In Source Generators

### `[GeneratedRegex]` (net7.0+)

Compile-time regex generation. Zero runtime compilation cost, AOT-compatible.

```csharp

public partial class Validators
{
    [GeneratedRegex(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
        RegexOptions.Compiled | RegexOptions.IgnoreCase)]
    private static partial Regex EmailRegex();

    public static bool IsValidEmail(string email)
        => EmailRegex().IsMatch(email);
}

```text

**Key rules:**

- Method must be `static partial` returning `Regex`
- Place on `partial class` (or `partial struct`)
- Replaces `new Regex(...)` with zero allocation at runtime
- Supports all `RegexOptions` except `RegexOptions.Compiled` (which is ignored -- the source generator replaces it)

### `[LoggerMessage]` (net6.0+)

High-performance structured logging with zero-allocation at log-disabled levels.

```csharp

public static partial class LogMessages
{
    [LoggerMessage(Level = LogLevel.Information,
        Message = "Processing order {OrderId} for customer {CustomerId}")]
    public static partial void OrderProcessing(
        this ILogger logger, int orderId, string customerId);

    [LoggerMessage(Level = LogLevel.Error,
        Message = "Failed to process order {OrderId}")]
    public static partial void OrderProcessingFailed(
        this ILogger logger, int orderId, Exception exception);
}

// Usage
logger.OrderProcessing(order.Id, order.CustomerId);

```text

**Key rules:**

- Methods must be `static partial` in a `partial class`
- Parameters matching `{Placeholder}` in the message are logged as structured data
- `Exception` parameter is logged automatically (do not include in message template)
- Event IDs are auto-assigned if not specified; specify explicit IDs for stable telemetry

### System.Text.Json Source Generation (net6.0+)

AOT-compatible JSON serialization. Eliminates runtime reflection.

```csharp

[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
[JsonSerializable(typeof(Customer))]
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public partial class AppJsonContext : JsonSerializerContext;

```json

#### Registration in ASP.NET Core

```csharp

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});

// Or for Minimal APIs
app.MapGet("/orders/{id}", async (int id, IOrderService service) =>
{
    var order = await service.GetByIdAsync(id);
    return order is not null
        ? Results.Ok(order)
        : Results.NotFound();
});

```text

#### Manual Serialization

```csharp

// Serialize
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);

// Deserialize
var order = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);

// With stream
await JsonSerializer.SerializeAsync(stream, orders,
    AppJsonContext.Default.ListOrder);

```json

**Key rules:**

- Register all types that need serialization in `[JsonSerializable]` attributes
- Use `TypeInfoResolverChain` (net8.0+) to combine multiple contexts
- Required for Native AOT -- reflection-based serialization is trimmed
- See [skill:dotnet-csharp-modern-patterns] for related C# features used in generated code

### `[JsonSerializable]` with Polymorphism (net7.0+)

```csharp

[JsonDerivedType(typeof(CreditCardPayment), "credit")]
[JsonDerivedType(typeof(BankTransferPayment), "bank")]
public abstract class Payment
{
    public decimal Amount { get; init; }
}

public class CreditCardPayment : Payment
{
    public required string CardLast4 { get; init; }
}

public class BankTransferPayment : Payment
{
    public required string AccountNumber { get; init; }
}

[JsonSerializable(typeof(Payment))]
public partial class PaymentJsonContext : JsonSerializerContext;

```json

---

## Generator Reference: Packaging and Consumption

### Referencing a Generator in a Consuming Project

```xml

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

```csharp

### NuGet Package Layout

When shipping a generator as a NuGet package, place the assembly under `analyzers/dotnet/cs/`:

```text

MyGenerator.nupkg
  analyzers/
    dotnet/
      cs/
        MyGenerator.dll
  lib/
    netstandard2.0/
      _._   (empty placeholder if no runtime dependency)

```text

```xml

<!-- In the generator .csproj -->
<PropertyGroup>
  <IncludeBuildOutput>false</IncludeBuildOutput>
  <DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>

<ItemGroup>
  <None Include="$(OutputPath)\$(AssemblyName).dll"
        Pack="true"
        PackagePath="analyzers/dotnet/cs" />
</ItemGroup>

```text

---

## Debugging Source Generators

```csharp

// Add to Initialize() for attach-debugger workflow
#if DEBUG
if (!System.Diagnostics.Debugger.IsAttached)
{
    System.Diagnostics.Debugger.Launch();
}
#endif

```text

Alternatively, emit generated files to disk for inspection:

```xml

<!-- In the consuming project -->
<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

```text

Add `Generated/` to `.gitignore`.

---

## References

- [Source Generator Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md)
- [Incremental Generators](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md)
- [GeneratedRegex source generator](https://learn.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-source-generators)
- [Compile-time logging source generation](https://learn.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator)
- [System.Text.Json source generation](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/source-generation)
- [.NET Framework Design Guidelines](https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/)
Weekly Installs
1
First Seen
11 days ago
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1