From 33fdb80c333651035229910bb5d86732e6393178 Mon Sep 17 00:00:00 2001 From: Koen Date: Thu, 4 Jun 2026 00:03:36 +0000 Subject: [PATCH] fixed generator cache being stale on cross-file edit --- ...iveForMemberCompilationEqualityComparer.cs | 74 ------- ...ionSyntaxAndCompilationEqualityComparer.cs | 75 ------- ...MemberDeclarationSyntaxEqualityComparer.cs | 46 ----- .../Emitter/ExpressionTreeEmitter.cs | 4 +- .../Emitter/SynthesizedPropertyEmitter.cs | 3 +- .../ExpressiveGenerator.cs | 192 +++++++++++------- .../Infrastructure/GeneratorOutput.cs | 107 ++++++++++ .../ExpressiveForInterpreter.cs | 20 +- .../ExpressiveInterpreter.BodyProcessors.cs | 14 +- .../ExpressiveInterpreter.Helpers.cs | 2 +- .../Interpretation/ExpressiveInterpreter.cs | 2 +- .../ExpressivePropertyInterpreter.cs | 4 +- .../PolyfillInterceptorGenerator.cs | 18 +- .../IncrementalCachingTests.cs | 121 +++++++++++ 14 files changed, 389 insertions(+), 293 deletions(-) delete mode 100644 src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs delete mode 100644 src/ExpressiveSharp.Generator/Comparers/MemberDeclarationSyntaxAndCompilationEqualityComparer.cs delete mode 100644 src/ExpressiveSharp.Generator/Comparers/MemberDeclarationSyntaxEqualityComparer.cs create mode 100644 src/ExpressiveSharp.Generator/Infrastructure/GeneratorOutput.cs create mode 100644 tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/IncrementalCachingTests.cs diff --git a/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs b/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs deleted file mode 100644 index 48059f99..00000000 --- a/src/ExpressiveSharp.Generator/Comparers/ExpressiveForMemberCompilationEqualityComparer.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System.Runtime.CompilerServices; -using ExpressiveSharp.Generator.Models; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace ExpressiveSharp.Generator.Comparers; - -// Mirrors MemberDeclarationSyntaxAndCompilationEqualityComparer for the [ExpressiveFor] pipeline. -internal class ExpressiveForMemberCompilationEqualityComparer - : IEqualityComparer<((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)> -{ - private readonly static MemberDeclarationSyntaxEqualityComparer _memberComparer = new(); - - public bool Equals( - ((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) x, - ((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) y) - { - var (xLeft, xCompilation) = x; - var (yLeft, yCompilation) = y; - - if (ReferenceEquals(xLeft.Member, yLeft.Member) && - ReferenceEquals(xCompilation, yCompilation) && - xLeft.GlobalOptions == yLeft.GlobalOptions) - { - return true; - } - - if (!ReferenceEquals(xLeft.Member.SyntaxTree, yLeft.Member.SyntaxTree)) - { - return false; - } - - if (xLeft.Attribute != yLeft.Attribute) - { - return false; - } - - if (xLeft.GlobalOptions != yLeft.GlobalOptions) - { - return false; - } - - if (!_memberComparer.Equals(xLeft.Member, yLeft.Member)) - { - return false; - } - - return xCompilation.ExternalReferences.SequenceEqual(yCompilation.ExternalReferences); - } - - public int GetHashCode(((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) obj) - { - var (left, compilation) = obj; - unchecked - { - var hash = 17; - hash = hash * 31 + _memberComparer.GetHashCode(left.Member); - hash = hash * 31 + RuntimeHelpers.GetHashCode(left.Member.SyntaxTree); - hash = hash * 31 + left.Attribute.GetHashCode(); - hash = hash * 31 + left.GlobalOptions.GetHashCode(); - - var references = compilation.ExternalReferences; - var referencesHash = 17; - referencesHash = referencesHash * 31 + references.Length; - foreach (var reference in references) - { - referencesHash = referencesHash * 31 + RuntimeHelpers.GetHashCode(reference); - } - hash = hash * 31 + referencesHash; - - return hash; - } - } -} diff --git a/src/ExpressiveSharp.Generator/Comparers/MemberDeclarationSyntaxAndCompilationEqualityComparer.cs b/src/ExpressiveSharp.Generator/Comparers/MemberDeclarationSyntaxAndCompilationEqualityComparer.cs deleted file mode 100644 index 160d9b57..00000000 --- a/src/ExpressiveSharp.Generator/Comparers/MemberDeclarationSyntaxAndCompilationEqualityComparer.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Runtime.CompilerServices; -using ExpressiveSharp.Generator.Models; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace ExpressiveSharp.Generator.Comparers; - -internal class MemberDeclarationSyntaxAndCompilationEqualityComparer - : IEqualityComparer<((MemberDeclarationSyntax Member, ExpressiveAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)> -{ - private readonly static MemberDeclarationSyntaxEqualityComparer _memberComparer = new(); - - public bool Equals( - ((MemberDeclarationSyntax Member, ExpressiveAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) x, - ((MemberDeclarationSyntax Member, ExpressiveAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) y) - { - var (xLeft, xCompilation) = x; - var (yLeft, yCompilation) = y; - - if (ReferenceEquals(xLeft.Member, yLeft.Member) && - ReferenceEquals(xCompilation, yCompilation) && - xLeft.GlobalOptions == yLeft.GlobalOptions) - { - return true; - } - - // Roslyn reuses SyntaxTree instances for unchanged files even when the Compilation - // object itself is new due to edits elsewhere — pointer comparison is enough. - if (!ReferenceEquals(xLeft.Member.SyntaxTree, yLeft.Member.SyntaxTree)) - { - return false; - } - - if (xLeft.Attribute != yLeft.Attribute) - { - return false; - } - - if (xLeft.GlobalOptions != yLeft.GlobalOptions) - { - return false; - } - - if (!_memberComparer.Equals(xLeft.Member, yLeft.Member)) - { - return false; - } - - return xCompilation.ExternalReferences.SequenceEqual(yCompilation.ExternalReferences); - } - - public int GetHashCode(((MemberDeclarationSyntax Member, ExpressiveAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation) obj) - { - var (left, compilation) = obj; - unchecked - { - var hash = 17; - hash = hash * 31 + _memberComparer.GetHashCode(left.Member); - hash = hash * 31 + RuntimeHelpers.GetHashCode(left.Member.SyntaxTree); - hash = hash * 31 + left.Attribute.GetHashCode(); - hash = hash * 31 + left.GlobalOptions.GetHashCode(); - - var references = compilation.ExternalReferences; - var referencesHash = 17; - referencesHash = referencesHash * 31 + references.Length; - foreach (var reference in references) - { - referencesHash = referencesHash * 31 + RuntimeHelpers.GetHashCode(reference); - } - hash = hash * 31 + referencesHash; - - return hash; - } - } -} diff --git a/src/ExpressiveSharp.Generator/Comparers/MemberDeclarationSyntaxEqualityComparer.cs b/src/ExpressiveSharp.Generator/Comparers/MemberDeclarationSyntaxEqualityComparer.cs deleted file mode 100644 index d6944c0b..00000000 --- a/src/ExpressiveSharp.Generator/Comparers/MemberDeclarationSyntaxEqualityComparer.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Runtime.CompilerServices; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace ExpressiveSharp.Generator.Comparers; - -internal class MemberDeclarationSyntaxEqualityComparer : IEqualityComparer -{ - public bool Equals(MemberDeclarationSyntax x, MemberDeclarationSyntax y) - { - if (ReferenceEquals(x, y)) - { - return true; - } - - // Roslyn reuses SyntaxTree objects for unchanged files, so a new SyntaxTree - // means the file was edited even if this specific node text looks the same. - if (!ReferenceEquals(x.SyntaxTree, y.SyntaxTree)) - { - return false; - } - - if (x.RawKind != y.RawKind) - { - return false; - } - - if (x.FullSpan.Length != y.FullSpan.Length) - { - return false; - } - - return x.IsEquivalentTo(y); - } - - public int GetHashCode(MemberDeclarationSyntax obj) - { - unchecked - { - var hash = 17; - hash = hash * 31 + RuntimeHelpers.GetHashCode(obj.SyntaxTree); - hash = hash * 31 + obj.RawKind; - hash = hash * 31 + obj.FullSpan.Length; - return hash; - } - } -} diff --git a/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs b/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs index aa400e41..aea48924 100644 --- a/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs +++ b/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs @@ -35,7 +35,7 @@ internal sealed class ExpressionTreeEmitter SymbolDisplayFormat.FullyQualifiedFormat; private readonly SemanticModel _semanticModel; - private readonly SourceProductionContext? _context; + private readonly GeneratorOutputContext? _context; private readonly ReflectionFieldCache _fieldCache; private readonly List _lines = new(); private readonly Dictionary _symbolToVar = new(SymbolEqualityComparer.Default); @@ -56,7 +56,7 @@ internal sealed class ExpressionTreeEmitter public ExpressionTreeEmitter( SemanticModel semanticModel, - SourceProductionContext? context = null, + GeneratorOutputContext? context = null, string varPrefix = "", string? delegateVarName = null) { diff --git a/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs b/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs index ef6b2000..bd170ba2 100644 --- a/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs +++ b/src/ExpressiveSharp.Generator/Emitter/SynthesizedPropertyEmitter.cs @@ -1,4 +1,5 @@ using System.Text; +using ExpressiveSharp.Generator.Infrastructure; using ExpressiveSharp.Generator.Models; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -12,7 +13,7 @@ namespace ExpressiveSharp.Generator.Emitter; /// static internal class SynthesizedPropertyEmitter { - public static void Emit(SynthesizedPropertySpec spec, string generatedFileName, SourceProductionContext context) + public static void Emit(SynthesizedPropertySpec spec, string generatedFileName, GeneratorOutputContext context) { var source = BuildSource(spec); context.AddSource(generatedFileName, SourceText.From(source, Encoding.UTF8)); diff --git a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs index 0000f00e..699edc71 100644 --- a/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs +++ b/src/ExpressiveSharp.Generator/ExpressiveGenerator.cs @@ -18,6 +18,9 @@ namespace ExpressiveSharp.Generator; [Generator] public class ExpressiveGenerator : IIncrementalGenerator { + /// Tracking name for the value-equatable generated-source node (used by incremental-cache tests). + public const string ExpressiveSourcesTrackingName = "ExpressiveSources"; + private const string ExpressiveAttributeName = "ExpressiveSharp.ExpressiveAttribute"; private const string ExpressiveForAttributeName = "ExpressiveSharp.Mapping.ExpressiveForAttribute"; private const string ExpressiveForConstructorAttributeName = "ExpressiveSharp.Mapping.ExpressiveForConstructorAttribute"; @@ -65,53 +68,38 @@ public void Initialize(IncrementalGeneratorInitializationContext context) GlobalOptions: pair.Right )); - // Combine with the augmented compilation directly: validation has no synthesized-sibling - // conflicts in this pipeline (only [ExpressiveProperty] does), so binding-against-augmented - // is correct everywhere. - var compilationAndMemberPairs = memberDeclarationsWithGlobalOptions + // A member's output depends on the whole compilation (cross-file binding), so a comparer keyed + // on member syntax + ExternalReferences would serve stale output. Recompute on every compilation + // change; gate downstream on the value-equatable projections below. + var expressiveComputations = memberDeclarationsWithGlobalOptions .Combine(bindingCompilationProvider) - .WithComparer(new MemberDeclarationSyntaxAndCompilationEqualityComparer()); - - context.RegisterImplementationSourceOutput(compilationAndMemberPairs, - static (spc, source) => - { - var ((member, attribute, globalOptions), bindingCompilation) = source; - var semanticModel = bindingCompilation.GetSemanticModel(member.SyntaxTree); - var memberSymbol = semanticModel.GetDeclaredSymbol(member); + .Select(static (source, ct) => ComputeExpressiveMember(source, ct)); - if (memberSymbol is null) - { - return; - } - - Execute(member, semanticModel, memberSymbol, attribute, globalOptions, bindingCompilation, spc); - }); + var expressiveSources = expressiveComputations.Select(static (c, _) => + new EquatableArray(c?.Sources ?? ImmutableArray.Empty)) + .WithTrackingName(ExpressiveSourcesTrackingName); + context.RegisterImplementationSourceOutput(expressiveSources, + static (spc, sources) => EmitSources(sources, spc)); - var registryEntries = compilationAndMemberPairs.Select( - static (source, cancellationToken) => { - var ((member, _, _), bindingCompilation) = source; + // Diagnostics flow live (not value-cached) so their syntax-tree locations stay valid for + // #pragma warning / .editorconfig suppression; recomputed each run, so never stale. + var expressiveDiagnostics = expressiveComputations.Select(static (c, _) => + c?.Diagnostics ?? ImmutableArray.Empty); + context.RegisterImplementationSourceOutput(expressiveDiagnostics, + static (spc, diagnostics) => ReportDiagnostics(diagnostics, spc)); - var semanticModel = bindingCompilation.GetSemanticModel(member.SyntaxTree); - var memberSymbol = semanticModel.GetDeclaredSymbol(member, cancellationToken); - - if (memberSymbol is null) - { - return null; - } - - return ExtractRegistryEntry(memberSymbol); - }); + var registryEntries = expressiveComputations.Select(static (c, _) => c?.RegistryEntry); - var expressiveForDeclarations = CreateExpressiveForPipeline( + var expressiveForResults = CreateExpressiveForPipeline( context, globalOptions, bindingCompilationProvider, ExpressiveForAttributeName, ExpressiveForMemberKind.MethodOrProperty); - var expressiveForConstructorDeclarations = CreateExpressiveForPipeline( + var expressiveForConstructorResults = CreateExpressiveForPipeline( context, globalOptions, bindingCompilationProvider, ExpressiveForConstructorAttributeName, ExpressiveForMemberKind.Constructor); - var expressiveForRegistryEntries = expressiveForDeclarations.Select( - static (source, _) => ExtractRegistryEntryForExternal(source)); - var expressiveForConstructorRegistryEntries = expressiveForConstructorDeclarations.Select( - static (source, _) => ExtractRegistryEntryForExternal(source)); + var expressiveForRegistryEntries = expressiveForResults.Select( + static (result, _) => result?.RegistryEntry); + var expressiveForConstructorRegistryEntries = expressiveForConstructorResults.Select( + static (result, _) => result?.RegistryEntry); // ── [ExpressiveProperty] pipeline ─────────────────────────────────────── @@ -133,15 +121,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) builder.AddRange(forEntries); builder.AddRange(forCtorEntries); builder.AddRange(propEntries); - return builder.ToImmutable(); + // EquatableArray so the registry file re-emits only when the entries actually change. + return new EquatableArray(builder.ToImmutable()); }); context.RegisterImplementationSourceOutput( allRegistryEntries, - static (spc, entries) => ExpressionRegistryEmitter.Emit(entries, spc)); + static (spc, entries) => ExpressionRegistryEmitter.Emit(entries.AsImmutableArray, spc)); } - private static IncrementalValuesProvider<((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions), Compilation)> + private static IncrementalValuesProvider CreateExpressiveForPipeline( IncrementalGeneratorInitializationContext context, IncrementalValueProvider globalOptions, @@ -166,36 +155,99 @@ public void Initialize(IncrementalGeneratorInitializationContext context) GlobalOptions: pair.Right )); - // Combine with the augmented compilation directly. The augmented compilation is - // shared across the entire run via bindingCompilationProvider's Select cache, so - // we don't re-parse synthesized partials per member. - var compilationAndPairs = declarationsWithGlobalOptions + // Recompute per change (the target type lives cross-file); gate on the projections below. + var computations = declarationsWithGlobalOptions .Combine(bindingCompilationProvider) - .WithComparer(new ExpressiveForMemberCompilationEqualityComparer()); + .Select(static (source, ct) => ComputeExpressiveForMember(source, ct)); - // Collect all items and emit in a single batch to detect duplicates before AddSource. - // Per-item emission would crash the generator on duplicate hint names (Roslyn deduplicates - // after all per-item callbacks, not at the AddSource call site). - context.RegisterImplementationSourceOutput(compilationAndPairs.Collect(), - static (spc, items) => + // Collect + dedup by hint name before AddSource: two stubs may map to the same target (a + // per-item output would crash on the duplicate hint name). + var collectedSources = computations.Collect() + .Select(static (items, _) => { - var emittedFileNames = new HashSet(); - - foreach (var source in items) + var seen = new HashSet(); + var builder = ImmutableArray.CreateBuilder(); + foreach (var computation in items) { - var ((member, attribute, globalOptions), bindingCompilation) = source; - var semanticModel = bindingCompilation.GetSemanticModel(member.SyntaxTree); - var stubSymbol = semanticModel.GetDeclaredSymbol(member); - - if (stubSymbol is not (IMethodSymbol or IPropertySymbol)) + if (computation is null) continue; - - ExecuteFor(member, semanticModel, stubSymbol, attribute, globalOptions, - bindingCompilation, spc, emittedFileNames); + foreach (var source in computation.Sources) + { + if (seen.Add(source.HintName)) + builder.Add(source); + } } + return new EquatableArray(builder.ToImmutable()); }); - return compilationAndPairs; + context.RegisterImplementationSourceOutput(collectedSources, + static (spc, sources) => EmitSources(sources, spc)); + + // Diagnostics flow live per member (real locations preserved). + var diagnostics = computations.Select(static (c, _) => + c?.Diagnostics ?? ImmutableArray.Empty); + context.RegisterImplementationSourceOutput(diagnostics, + static (spc, ds) => ReportDiagnostics(ds, spc)); + + return computations; + } + + // emittedFileNames: null -> always emit; cross-member dedup happens when sources are collected. + private static MemberComputation? ComputeExpressiveForMember( + ((MemberDeclarationSyntax Member, ExpressiveForAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions) Left, Compilation BindingCompilation) source, + CancellationToken cancellationToken) + { + var ((member, attribute, globalOptions), bindingCompilation) = source; + var semanticModel = bindingCompilation.GetSemanticModel(member.SyntaxTree); + var stubSymbol = semanticModel.GetDeclaredSymbol(member, cancellationToken); + + if (stubSymbol is not (IMethodSymbol or IPropertySymbol)) + { + return null; + } + + var output = new GeneratorOutputContext(cancellationToken); + ExecuteFor(member, semanticModel, stubSymbol, attribute, globalOptions, + bindingCompilation, output, emittedFileNames: null); + var registryEntry = ExtractRegistryEntryForExternal(source); + + return new MemberComputation(output.Sources, output.Diagnostics, registryEntry); + } + + private static MemberComputation? ComputeExpressiveMember( + ((MemberDeclarationSyntax Member, ExpressiveAttributeData Attribute, ExpressiveGlobalOptions GlobalOptions) Left, Compilation BindingCompilation) source, + CancellationToken cancellationToken) + { + var ((member, attribute, globalOptions), bindingCompilation) = source; + var semanticModel = bindingCompilation.GetSemanticModel(member.SyntaxTree); + var memberSymbol = semanticModel.GetDeclaredSymbol(member, cancellationToken); + + if (memberSymbol is null) + { + return null; + } + + var output = new GeneratorOutputContext(cancellationToken); + Execute(member, semanticModel, memberSymbol, attribute, globalOptions, bindingCompilation, output); + var registryEntry = ExtractRegistryEntry(memberSymbol); + + return new MemberComputation(output.Sources, output.Diagnostics, registryEntry); + } + + private static void EmitSources(EquatableArray sources, SourceProductionContext context) + { + foreach (var source in sources.AsImmutableArray) + { + context.AddSource(source.HintName, SourceText.From(source.Text, Encoding.UTF8)); + } + } + + private static void ReportDiagnostics(ImmutableArray diagnostics, SourceProductionContext context) + { + foreach (var diagnostic in diagnostics) + { + context.ReportDiagnostic(diagnostic); + } } private static void Execute( @@ -205,7 +257,7 @@ private static void Execute( ExpressiveAttributeData expressiveAttribute, ExpressiveGlobalOptions globalOptions, Compilation? compilation, - SourceProductionContext context) + GeneratorOutputContext context) { var expressive = ExpressiveInterpreter.GetDescriptor( semanticModel, member, memberSymbol, expressiveAttribute, globalOptions, context, compilation); @@ -265,7 +317,7 @@ private static void EmitExpressionTreeSource( string generatedFileName, MemberDeclarationSyntax member, Compilation? compilation, - SourceProductionContext context) + GeneratorOutputContext context) { var emission = expressive.ExpressionTreeEmission!; var sb = new System.Text.StringBuilder(); @@ -432,7 +484,7 @@ private static void ExecuteFor( ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, Compilation compilation, - SourceProductionContext context, + GeneratorOutputContext context, HashSet? emittedFileNames = null) { var descriptor = ExpressiveForInterpreter.GetDescriptor( @@ -708,6 +760,8 @@ private static Compilation AugmentCompilation( context.RegisterSourceOutput(compilationAndPairsWithBinding.Collect(), static (spc, items) => { + // Already binds the live compilation per run; collector is just the shared emit path. + var output = new GeneratorOutputContext(spc.CancellationToken); var emittedFileNames = new HashSet(); foreach (var source in items) { @@ -721,8 +775,10 @@ private static Compilation AugmentCompilation( : bindingCompilation.GetSemanticModel(stub.SyntaxTree); ExecuteExpressiveProperty(stub, stubSymbol, semanticModel, bindingSemanticModel, - attribute, spc, emittedFileNames); + attribute, output, emittedFileNames); } + + output.FlushTo(spc); }); return compilationAndPairs; @@ -734,7 +790,7 @@ private static void ExecuteExpressiveProperty( SemanticModel semanticModel, SemanticModel bodyBindingSemanticModel, ExpressivePropertyAttributeData attribute, - SourceProductionContext context, + GeneratorOutputContext context, HashSet emittedFileNames) { var result = ExpressivePropertyInterpreter.GetDescriptor( diff --git a/src/ExpressiveSharp.Generator/Infrastructure/GeneratorOutput.cs b/src/ExpressiveSharp.Generator/Infrastructure/GeneratorOutput.cs new file mode 100644 index 00000000..65db5080 --- /dev/null +++ b/src/ExpressiveSharp.Generator/Infrastructure/GeneratorOutput.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using ExpressiveSharp.Generator.Registry; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace ExpressiveSharp.Generator.Infrastructure; + +/// A generated source file as value-equatable data, safe to cache in the pipeline. +internal readonly record struct GeneratedSource(string HintName, string Text); + +/// +/// One member's bind+emit output. and are value-data +/// (gated by their projections); hold live syntax-tree locations (required for +/// #pragma warning/.editorconfig suppression) so they flow uncached and are re-reported each run. +/// +internal sealed class MemberComputation( + ImmutableArray sources, + ImmutableArray diagnostics, + ExpressionRegistryEntry? registryEntry) +{ + public ImmutableArray Sources { get; } = sources; + public ImmutableArray Diagnostics { get; } = diagnostics; + public ExpressionRegistryEntry? RegistryEntry { get; } = registryEntry; +} + +/// +/// Collects ReportDiagnostic/AddSource so interpretation/emission can run inside an +/// incremental Select (where no exists). Mirrors the +/// members the generator uses, so call sites are unchanged. +/// +internal sealed class GeneratorOutputContext +{ + private readonly ImmutableArray.Builder _diagnostics = + ImmutableArray.CreateBuilder(); + private readonly ImmutableArray.Builder _sources = + ImmutableArray.CreateBuilder(); + + public GeneratorOutputContext(CancellationToken cancellationToken = default) + => CancellationToken = cancellationToken; + + public CancellationToken CancellationToken { get; } + + public void ReportDiagnostic(Diagnostic diagnostic) => _diagnostics.Add(diagnostic); + + public void AddSource(string hintName, SourceText sourceText) + => _sources.Add(new GeneratedSource(hintName, sourceText.ToString())); + + public void AddSource(string hintName, string source) + => _sources.Add(new GeneratedSource(hintName, source)); + + public ImmutableArray Diagnostics => _diagnostics.ToImmutable(); + public ImmutableArray Sources => _sources.ToImmutable(); + + /// Replays everything collected to a real context (for pipelines that run in a source-output). + public void FlushTo(SourceProductionContext context) + { + foreach (var diagnostic in _diagnostics) + context.ReportDiagnostic(diagnostic); + foreach (var source in _sources) + context.AddSource(source.HintName, SourceText.From(source.Text, System.Text.Encoding.UTF8)); + } +} + +/// with element-wise value equality (the default is by reference). +internal readonly struct EquatableArray(ImmutableArray array) : IEquatable> +{ + private readonly ImmutableArray _array = array; + + public ImmutableArray AsImmutableArray => _array.IsDefault ? ImmutableArray.Empty : _array; + + public int Length => AsImmutableArray.Length; + + public bool Equals(EquatableArray other) + { + var self = AsImmutableArray; + var that = other.AsImmutableArray; + if (self.Length != that.Length) + return false; + + var comparer = EqualityComparer.Default; + for (var i = 0; i < self.Length; i++) + { + if (!comparer.Equals(self[i], that[i])) + return false; + } + + return true; + } + + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + var comparer = EqualityComparer.Default; + foreach (var item in AsImmutableArray) + hash = hash * 31 + (item is null ? 0 : comparer.GetHashCode(item)); + return hash; + } + } + + public static implicit operator EquatableArray(ImmutableArray array) => new(array); +} diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs index e55b18b9..349b4691 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveForInterpreter.cs @@ -16,7 +16,7 @@ static internal class ExpressiveForInterpreter ISymbol stubSymbol, ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, - SourceProductionContext context, + GeneratorOutputContext context, Compilation compilation) { var stubIdentifierLocation = stubMember switch @@ -76,7 +76,7 @@ static internal class ExpressiveForInterpreter IMethodSymbol stubSymbol, ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, - SourceProductionContext context, + GeneratorOutputContext context, Compilation compilation, INamedTypeSymbol targetType) { @@ -124,7 +124,7 @@ static internal class ExpressiveForInterpreter IMethodSymbol stubSymbol, ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, - SourceProductionContext context, + GeneratorOutputContext context, Compilation compilation, INamedTypeSymbol targetType) { @@ -168,7 +168,7 @@ static internal class ExpressiveForInterpreter IPropertySymbol stubSymbol, ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, - SourceProductionContext context, + GeneratorOutputContext context, Compilation compilation, INamedTypeSymbol targetType, Location stubIdentifierLocation) @@ -273,7 +273,7 @@ static internal class ExpressiveForInterpreter IMethodSymbol stubSymbol, ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, - SourceProductionContext context, + GeneratorOutputContext context, INamedTypeSymbol targetType, IPropertySymbol targetProperty) { @@ -301,7 +301,7 @@ static internal class ExpressiveForInterpreter IMethodSymbol stubSymbol, ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, - SourceProductionContext context, + GeneratorOutputContext context, INamedTypeSymbol targetType, IMethodSymbol targetMethod) { @@ -327,7 +327,7 @@ static internal class ExpressiveForInterpreter IMethodSymbol stubSymbol, ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, - SourceProductionContext context, + GeneratorOutputContext context, INamedTypeSymbol targetType, string targetMemberName, System.Collections.Immutable.ImmutableArray targetParameters, @@ -387,7 +387,7 @@ static internal class ExpressiveForInterpreter IPropertySymbol stubSymbol, ExpressiveForAttributeData attributeData, ExpressiveGlobalOptions globalOptions, - SourceProductionContext context, + GeneratorOutputContext context, INamedTypeSymbol targetType, string targetMemberName) { @@ -446,7 +446,7 @@ static internal class ExpressiveForInterpreter private static ExpressiveDescriptor BuildDescriptorCore( SemanticModel semanticModel, - SourceProductionContext context, + GeneratorOutputContext context, SyntaxTree stubSyntaxTree, ISymbol stubSymbol, ExpressiveForAttributeData attributeData, @@ -541,7 +541,7 @@ private static bool HasExpressiveAttribute(ISymbol member, Compilation compilati } private static void ReportConflict( - SourceProductionContext context, + GeneratorOutputContext context, MethodDeclarationSyntax stubMethod, string memberName, INamedTypeSymbol targetType) diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs index b2bec7c8..7f07ce05 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.BodyProcessors.cs @@ -16,7 +16,7 @@ private static bool TryApplyMethodBody( ISymbol memberSymbol, SemanticModel semanticModel, DeclarationSyntaxRewriter declarationSyntaxRewriter, - SourceProductionContext context, + GeneratorOutputContext context, ExpressiveDescriptor descriptor, bool allowBlockBody) { @@ -66,7 +66,7 @@ private static bool TryApplyPropertyBody( ISymbol memberSymbol, SemanticModel semanticModel, DeclarationSyntaxRewriter declarationSyntaxRewriter, - SourceProductionContext context, + GeneratorOutputContext context, ExpressiveDescriptor descriptor, bool allowBlockBody) { @@ -127,7 +127,7 @@ private static bool TryApplyConstructorBody( ISymbol memberSymbol, SemanticModel semanticModel, DeclarationSyntaxRewriter declarationSyntaxRewriter, - SourceProductionContext context, + GeneratorOutputContext context, Compilation? compilation, ExpressiveDescriptor descriptor) { @@ -242,7 +242,7 @@ or Accessibility.Internal private static EmitResult EmitExpressionTree( SyntaxNode bodyExpression, SemanticModel semanticModel, - SourceProductionContext context, + GeneratorOutputContext context, ExpressiveDescriptor descriptor, IMethodSymbol methodSymbol) { @@ -261,7 +261,7 @@ private static EmitResult EmitExpressionTree( private static EmitResult EmitExpressionTreeForProperty( SyntaxNode bodyExpression, SemanticModel semanticModel, - SourceProductionContext context, + GeneratorOutputContext context, ExpressiveDescriptor descriptor, ISymbol memberSymbol) { @@ -317,7 +317,7 @@ private static void ValidateBlockBody( SemanticModel semanticModel, SyntaxNode bodySyntax, string memberName, - SourceProductionContext context) + GeneratorOutputContext context) { var operation = semanticModel.GetOperation(bodySyntax); if (operation is null) return; @@ -328,7 +328,7 @@ private static void ValidateBlockBody( private static void WalkOperations( IOperation operation, string memberName, - SourceProductionContext context) + GeneratorOutputContext context) { switch (operation) { diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.Helpers.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.Helpers.cs index 6fd050e6..4e92936b 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.Helpers.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.Helpers.cs @@ -71,7 +71,7 @@ private static void ApplyTypeParameters( } private static bool ReportRequiresBodyAndFail( - SourceProductionContext context, + GeneratorOutputContext context, SyntaxNode node, string memberName) { diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs index 11ae88bd..947e8bb8 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressiveInterpreter.cs @@ -15,7 +15,7 @@ static internal partial class ExpressiveInterpreter ISymbol memberSymbol, ExpressiveAttributeData expressiveAttribute, ExpressiveGlobalOptions globalOptions, - SourceProductionContext context, + GeneratorOutputContext context, Compilation? compilation = null) { var isExtensionMember = memberSymbol.ContainingType is { IsExtension: true }; diff --git a/src/ExpressiveSharp.Generator/Interpretation/ExpressivePropertyInterpreter.cs b/src/ExpressiveSharp.Generator/Interpretation/ExpressivePropertyInterpreter.cs index 812bb686..b24f7902 100644 --- a/src/ExpressiveSharp.Generator/Interpretation/ExpressivePropertyInterpreter.cs +++ b/src/ExpressiveSharp.Generator/Interpretation/ExpressivePropertyInterpreter.cs @@ -29,7 +29,7 @@ public static (ExpressiveDescriptor Descriptor, SynthesizedPropertySpec Spec)? G PropertyDeclarationSyntax stubProperty, IPropertySymbol stubSymbol, ExpressivePropertyAttributeData attributeData, - SourceProductionContext context, + GeneratorOutputContext context, SemanticModel? bodyBindingSemanticModel = null) { var stubLocation = stubProperty.Identifier.GetLocation(); @@ -181,7 +181,7 @@ public static (ExpressiveDescriptor Descriptor, SynthesizedPropertySpec Spec)? G private static ExpressiveDescriptor? BuildDescriptor( SemanticModel semanticModel, - SourceProductionContext context, + GeneratorOutputContext context, PropertyDeclarationSyntax stubProperty, IPropertySymbol stubSymbol, ExpressivePropertyAttributeData attributeData, diff --git a/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs b/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs index b5f1a4ac..eb6dba36 100644 --- a/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs +++ b/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs @@ -157,14 +157,20 @@ inv.Expression is MemberAccessExpressionSyntax && .WithComparer(FileAndSynthesizedSourcesComparer.Instance); context.RegisterSourceOutput(filesWithCompilationAndSynth, - static (spc, pair) => ProcessFileAndEmit(pair.Left.Left, pair.Left.Right, pair.Right, spc)); + static (spc, pair) => + { + // Keeps its existing caching; collector is just the shared emit path, flushed inline. + var output = new GeneratorOutputContext(spc.CancellationToken); + ProcessFileAndEmit(pair.Left.Left, pair.Left.Right, pair.Right, output); + output.FlushTo(spc); + }); } private static void ProcessFileAndEmit( CompilationUnitSyntax unit, Compilation compilation, ImmutableArray<(string HintName, string Source)> synthesizedSources, - SourceProductionContext spc) + GeneratorOutputContext spc) { var ct = spc.CancellationToken; @@ -300,7 +306,7 @@ private static string GetFileTag(string sourcePath) int line, int col, string fileTag, - SourceProductionContext spc) + GeneratorOutputContext spc) { if (method.TypeArguments.Length != 1) return null; @@ -368,7 +374,7 @@ private static string GetFileTag(string sourcePath) int line, int col, string fileTag, - SourceProductionContext spc) + GeneratorOutputContext spc) { if (model.GetTypeInfo(ma.Expression).Type is not INamedTypeSymbol receiverType) return null; @@ -425,7 +431,7 @@ private static string GetFileTag(string sourcePath) LambdaExpressionSyntax lambda, ITypeSymbol elementSymbol, SemanticModel model, - SourceProductionContext spc, + GeneratorOutputContext spc, string delegateTypeFqn, string assignToVariable = "__lambda", string varPrefix = "", @@ -533,7 +539,7 @@ private static string MethodId(string op, string fileTag, int line, int col) => $"__Polyfill_{op}_{fileTag}_{line}_{col}"; private static string? EmitGenericLambda( - InvocationExpressionSyntax inv, SemanticModel model, SourceProductionContext spc, + InvocationExpressionSyntax inv, SemanticModel model, GeneratorOutputContext spc, string interceptAttr, int line, int col, string fileTag, string methodName, INamedTypeSymbol elemSym, string elemFqn, IMethodSymbol method, List funcParamIndices, string targetTypeFqn) diff --git a/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/IncrementalCachingTests.cs b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/IncrementalCachingTests.cs new file mode 100644 index 00000000..695c0055 --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/ExpressiveGenerator/IncrementalCachingTests.cs @@ -0,0 +1,121 @@ +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ExpressiveSharp.Generator.Tests.Infrastructure; + +namespace ExpressiveSharp.Generator.Tests.ExpressiveGenerator; + +// Guards that a cross-file edit doesn't serve stale generated output (and that an unrelated edit +// doesn't churn it). See the comparer history in ExpressiveGenerator for why. +[TestClass] +public class IncrementalCachingTests : GeneratorTestBase +{ + private const string MemberSource = """ + using ExpressiveSharp; + namespace App + { + public static class Q + { + [Expressive] + public static long Get(Thing t) => t.Number; + } + } + """; + + // v1: Number is int -> body widens int->long, so the emitter inserts an Expression.Convert. + private const string ModelsIntVersion = """ + namespace App { public class Thing { public int Number { get; set; } } } + """; + + // v2: Number is long -> body is already long, so NO Expression.Convert is emitted. + private const string ModelsLongVersion = """ + namespace App { public class Thing { public long Number { get; set; } } } + """; + + // An unrelated edit to the models file that does not change Get's generated output. + private const string ModelsIntVersionWithUnrelatedMember = """ + namespace App { public class Thing { public int Number { get; set; } public int Other { get; set; } } } + """; + + private static GeneratorDriver CreateDriver() => CSharpGeneratorDriver.Create( + new[] { new global::ExpressiveSharp.Generator.ExpressiveGenerator().AsSourceGenerator() }, + driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true)); + + private CSharpCompilation CreateCompilation(SyntaxTree memberTree, SyntaxTree modelsTree) => + CSharpCompilation.Create( + "compilation", + new[] { memberTree, modelsTree }, + GetDefaultReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + private static string GetMemberSourceText(GeneratorDriverRunResult run) => + run.GeneratedTrees.Single(t => t.FilePath.Contains("Get_P0_App_Thing")).GetText().ToString(); + + [TestMethod] + public void CrossFileTypeChange_IncrementalOutputMatchesFreshRun() + { + var memberTree = CSharpSyntaxTree.ParseText(MemberSource, path: "Member.cs"); + var modelsV1 = CSharpSyntaxTree.ParseText(ModelsIntVersion, path: "Models.cs"); + + var c1 = CreateCompilation(memberTree, modelsV1); + var driver = CreateDriver().RunGenerators(c1); + + // Edit ONLY Models.cs (Member.cs's tree reference is preserved, so a naive comparer would + // treat this member as unchanged). + var modelsV2 = CSharpSyntaxTree.ParseText(ModelsLongVersion, path: "Models.cs"); + var c2 = c1.ReplaceSyntaxTree(modelsV1, modelsV2); + + driver = driver.RunGenerators(c2); + var incremental = GetMemberSourceText(driver.GetRunResult()); + + // Ground truth: a fresh driver on the final compilation. + var fresh = GetMemberSourceText(CreateDriver().RunGenerators(c2).GetRunResult()); + + TestContext.WriteLine("Incremental:\n" + incremental); + TestContext.WriteLine("Fresh:\n" + fresh); + + Assert.AreEqual(fresh, incremental, + "After a cross-file type change, incremental output must match a from-scratch run (no stale cache)."); + // Sanity: the change really did alter the output (int->long removes the Convert), so the test + // is actually exercising staleness rather than comparing two identical strings. + StringAssert.Contains(incremental, "long", "Expected the long-typed property to be reflected."); + Assert.IsFalse(incremental.Contains("Convert"), + "With Number typed as long, no widening Convert should remain in the generated tree."); + } + + [TestMethod] + public void UnrelatedCrossFileEdit_DoesNotInvalidateGeneratedSources() + { + var memberTree = CSharpSyntaxTree.ParseText(MemberSource, path: "Member.cs"); + var modelsV1 = CSharpSyntaxTree.ParseText(ModelsIntVersion, path: "Models.cs"); + + var c1 = CreateCompilation(memberTree, modelsV1); + var driver = CreateDriver().RunGenerators(c1); + var text1 = GetMemberSourceText(driver.GetRunResult()); + + // Add an unrelated member to Thing — Get's generated output is unaffected. + var modelsV1b = CSharpSyntaxTree.ParseText(ModelsIntVersionWithUnrelatedMember, path: "Models.cs"); + var c2 = c1.ReplaceSyntaxTree(modelsV1, modelsV1b); + + driver = driver.RunGenerators(c2); + var run2 = driver.GetRunResult(); + + Assert.AreEqual(text1, GetMemberSourceText(run2), + "Precondition: the unrelated edit should not change Get's generated text."); + + // Value unchanged => step reason Unchanged => downstream AddSource isn't re-run (no churn). + var sourceSteps = run2.Results + .Single() + .TrackedSteps[global::ExpressiveSharp.Generator.ExpressiveGenerator.ExpressiveSourcesTrackingName]; + + foreach (var step in sourceSteps) + { + foreach (var output in step.Outputs) + { + Assert.AreEqual(IncrementalStepRunReason.Unchanged, output.Reason, + "Value-gating should mark the generated source 'Unchanged' for an unrelated cross-file edit."); + } + } + } +}