Skip to content

Upgrade Splat.DI.SourceGenerator to Modern Incremental Generator #272

@glennawatson

Description

@glennawatson

Objective

Upgrade the legacy ISourceGenerator in Splat.DI.SourceGenerator to a high-performance IIncrementalGenerator using Roslyn 4.10.0. The focus is on performance, memory efficiency, and cacheability while strictly targeting netstandard2.0. This involves modernizing the .csproj file, implementing an efficient, cache-friendly incremental pipeline with chained steps and liberal use of records, and updating the README.md to reflect the changes.

1. Modernize the .csproj File

The current .csproj includes outdated practices (e.g., ILRepack) and unnecessary dependencies. Update it to align with modern source generator standards while maintaining netstandard2.0.

Actions

  • Remove ILRepack and Related Targets: Replace with <PrivateAssets>all</PrivateAssets> and <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> on PackageReference items for efficient dependency management.
  • Target Framework: Strictly target netstandard2.0 for Roslyn compatibility. Avoid multi-targeting to simplify packaging.
  • Update CodeAnalysis Packages: Upgrade to Microsoft.CodeAnalysis.CSharp 4.10.0 and Microsoft.CodeAnalysis.Analyzers 3.3.4 (or latest stable).
  • Remove Unnecessary Packages: Eliminate ReactiveMarbles.RoslynHelpers, Microsoft.CodeAnalysis.Common, and Microsoft.CodeAnalysis.CSharp.Workspaces, as they are not needed.
  • Add PolySharp: Include PolySharp (version 1.14.1 or latest) to enable modern C# features (e.g., records, init-only properties) in netstandard2.0.
  • Ensure Correct Packaging: Configure the project to output the DLL to the analyzers/dotnet/cs folder in the NuGet package. Set <IsRoslynAnalyzer>true</IsRoslynAnalyzer>, <DevelopmentDependency>true</DevelopmentDependency>, and <IncludeBuildOutput>false</IncludeBuildOutput>.
  • Clean Up: Remove <NoWarn>AD0001</NoWarn>, <DebugType>full</DebugType>, and custom targets like PackBuildOutputs if handled by SDK defaults. Set <LangVersion>12.0</LangVersion> for modern syntax.

Proposed .csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>12.0</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynAnalyzer>true</IsRoslynAnalyzer>
    <DevelopmentDependency>true</DevelopmentDependency>
    <IncludeBuildOutput>false</IncludeBuildOutput>
    <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
    <PackageDescription>Produces DI registration for both property and constructor injection using the Splat locators.</PackageDescription>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" />
    <PackageReference Include="PolySharp" Version="1.14.1" PrivateAssets="all" />
  </ItemGroup>

</Project>

Verification

  • Build and pack the project to confirm the NuGet package places the analyzer DLL in analyzers/dotnet/cs.
  • Test compatibility with Visual Studio 17.10+.

2. Implement Cache-Friendly Incremental Pipeline

Convert the ISourceGenerator.Execute logic to an IIncrementalGenerator pipeline with efficient chaining of steps and liberal use of records for data transfer. The pipeline must be cache-friendly, memory-efficient, and avoid LINQ in performance-critical paths.

Key Principles

  • Immutable Data Models: Use C# record types extensively for data transfer between pipeline steps. Records ensure immutability and value-based equality, optimizing Roslyn’s caching.
  • Efficient Chaining: Chain pipeline steps using Select, SelectMany, and Where to minimize intermediate collections and leverage Roslyn’s incremental execution.
  • Performance: Avoid LINQ in hot paths (e.g., no ToList, Where, or Select in loops). Use foreach loops and pre-sized collections (e.g., List<T> with capacity).
  • Memory Efficiency: Filter invalid candidates early to reduce memory usage.
  • Abstraction: Separate syntax collection, semantic extraction, and code emission into distinct, testable methods.
  • Cache Friendliness: Ensure all transforms are pure functions (output depends only on input) to maximize Roslyn’s caching benefits.
  • Attribute Support: Use ForAttributeWithMetadataName for efficient attribute detection.

Pipeline Steps

Implement in the Initialize(IncrementalGeneratorInitializationContext context) method with chained providers:

  1. Syntax Provider

    • Use context.SyntaxProvider.ForAttributeWithMetadataName to efficiently find ClassDeclarationSyntax nodes with the fully qualified Splat.DI.Attributes.RegisterAttribute.
    • Record for this step:
      public record AttributedClass(
          ClassDeclarationSyntax Syntax,
          string FullyQualifiedAttributeName
      );
    • Chain directly to the transform step to avoid unnecessary allocations.
  2. Transform Provider

    • Chain a Select transform to extract semantic information from each AttributedClass.
    • For each class:
      • Get the SemanticModel using context.CompilationProvider.
      • Extract class name, namespace, accessibility, and dependencies (constructor/property injections).
      • Validate the class (e.g., not abstract, valid dependencies) and return null for invalid candidates.
      • Return a record for valid classes.
    • Record for this step:
      public record InjectionTarget(
          string ClassName,
          string Namespace,
          Accessibility Accessibility,
          ImmutableArray<DependencyInfo> Dependencies
      );
      
      public record DependencyInfo(
          string Name,
          string Type,
          bool IsProperty // true for property injection, false for constructor
      );
    • Chain a Where clause to filter out null results.
    • Use foreach loops and List<T> with pre-allocated capacity for dependency collection to avoid LINQ allocations.
  3. Aggregation Step

    • Chain a Collect step to gather all InjectionTarget records into an ImmutableArray<InjectionTarget>.
    • Record for this step:
      public record RegistrationGroup(
          ImmutableArray<InjectionTarget> Targets
      );
    • This step enables grouping all registrations into a single file (if needed) while maintaining cacheability.
  4. Source Emitter

    • Chain a Select to the RegistrationGroup for code generation.
    • Use StringBuilder to construct the source code efficiently.
    • Generate a single file (e.g., GeneratedRegistrations.cs) with all DI registrations.
    • Emit via context.AddSource("GeneratedRegistrations.cs", generatedCode).
    • Record for this step (optional, for clarity):
      public record GeneratedSource(
          string FileName,
          string SourceCode
      );

Example Pipeline Code

public void Initialize(IncrementalGeneratorInitializationContext context)
{
    // Step 1: Syntax Provider
    var attributedClasses = context.SyntaxProvider
        .ForAttributeWithMetadataName(
            "Splat.DI.Attributes.RegisterAttribute",
            (node, _) => node is ClassDeclarationSyntax,
            (ctx, _) => new AttributedClass((ClassDeclarationSyntax)ctx.Node, "Splat.DI.Attributes.RegisterAttribute")
        );

    // Step 2: Transform Provider
    var injectionTargets = attributedClasses
        .Select((attributedClass, ct) =>
        {
            var semanticModel = ctx.Compilation.GetSemanticModel(attributedClass.Syntax.SyntaxTree);
            var symbol = semanticModel.GetDeclaredSymbol(attributedClass.Syntax, ct);
            if (symbol == null || symbol.IsAbstract) return null;

            var dependencies = new List<DependencyInfo>(capacity: 10); // Pre-size for efficiency
            foreach (var member in symbol.GetMembers())
            {
                // Extract properties/constructors with attributes (logic omitted for brevity)
                // Add to dependencies list
            }

            return new InjectionTarget(
                symbol.Name,
                symbol.ContainingNamespace.ToDisplayString(),
                symbol.DeclaredAccessibility,
                dependencies.ToImmutableArray()
            );
        })
        .Where(target => target != null);

    // Step 3: Aggregation
    var registrationGroup = injectionTargets
        .Collect()
        .Select((targets, _) => new RegistrationGroup(targets));

    // Step 4: Source Emitter
    context.RegisterSourceOutput(registrationGroup, (spc, group) =>
    {
        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated />");
        sb.AppendLine("namespace Splat.DI.Generated;");
        sb.AppendLine("public static class Registrations");
        sb.AppendLine("{");
        foreach (var target in group.Targets)
        {
            // Generate registration code (logic omitted for brevity)
        }
        sb.AppendLine("}");

        spc.AddSource("GeneratedRegistrations.cs", sb.ToString());
    });
}

Considerations

  • Edge Cases: Handle partial classes, generics, or invalid attributes by filtering in the transform step.
  • Performance: Pre-size collections (e.g., List<DependencyInfo>(capacity: 10)) and use foreach to avoid allocations.
  • Records: Use records at each step (AttributedClass, InjectionTarget, DependencyInfo, RegistrationGroup, GeneratedSource) for immutability and caching.
  • Output: Ensure the generated code matches the legacy generator’s output functionally.
  • PolySharp: Leverage records and modern C# features via PolySharp in netstandard2.0.

3. Update README.md

Update the README.md to reflect the modernized generator and its benefits.

Actions

  • Remove Obsolete Information: Eliminate references to the legacy generator’s behavior (e.g., full re-execution on every change).
  • Add Performance Benefits: Include a section:

    This generator uses Roslyn’s incremental model with efficient pipeline chaining and immutable records, significantly improving Visual Studio performance by caching intermediate results, reducing memory usage, and avoiding unnecessary re-computations during editing.

  • Verify Usage Instructions: Confirm instructions for installing the NuGet package, applying RegisterAttribute, and integrating with Splat locators are accurate for Visual Studio 17.10+.
  • Remove non-incremental pipeline - Only use incremental pipelines since we are just targetting VS 17.10
  • Remove the SynfaxFactory - Use raw literals, and interpolated raw literals in more modern C# versions only. Can use StringBuilder only in small circumstances, but then just do like
   sb.Append($$"""
                      the text {{data.Item}};
                      """);

for multi line input.

Acceptance Criteria

  • Generator converted to IIncrementalGenerator using Roslyn 4.10.0.
  • .csproj updated: ILRepack removed, unnecessary packages eliminated, PolySharp added, strictly targeting netstandard2.0.
  • Pipeline uses efficient chaining (Select, Where, Collect), is cache-friendly (pure transforms, records), avoids LINQ in hot paths, and is memory-efficient (early filtering).
  • Liberal use of records (AttributedClass, InjectionTarget, DependencyInfo, RegistrationGroup, GeneratedSource) for data transfer.
  • Logic abstracted into testable methods (syntax collection, semantic extraction, code emission).
  • README.md updated with modern architecture details and performance benefits.
  • Existing tests pass; new tests added for pipeline steps if necessary.
  • Generated output functionally identical to the legacy version.
  • Migrated the old non-incremental pipeline
  • Build, pack, and test in a sample project with Visual Studio 17.10+.

References

Notes

  • Test thoroughly in Visual Studio 17.10+ to verify IDE performance improvements.
  • Validate NuGet package compatibility with downstream projects.
  • Consider adding a sample project to demonstrate usage and performance gains.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions