Skip to content

Commit bb25846

Browse files
authored
Merge pull request #73 from EFNext/fix/polyfill-generator-incremental-cache
Fixed incremental cache busting issue
2 parents 5fcdd86 + 0979d88 commit bb25846

2 files changed

Lines changed: 143 additions & 67 deletions

File tree

src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs

Lines changed: 44 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Collections.Immutable;
22
using System.Text;
3+
using System.Threading;
34
using ExpressiveSharp.Generator.Comparers;
45
using ExpressiveSharp.Generator.Emitter;
56
using ExpressiveSharp.Generator.Infrastructure;
@@ -24,6 +25,9 @@ public class PolyfillInterceptorGenerator : IIncrementalGenerator
2425

2526
private const string ExpressivePropertyAttributeName = "ExpressiveSharp.Mapping.ExpressivePropertyAttribute";
2627

28+
/// <summary>Tracking name for the value-equatable interceptor-source node (used by incremental-cache tests).</summary>
29+
public const string InterceptorSourcesTrackingName = "InterceptorSources";
30+
2731
private const string ClosureHelperSource = """
2832
2933
file static class __ClosureHelper
@@ -112,12 +116,13 @@ inv.Expression is MemberAccessExpressionSyntax &&
112116
.Where(static x => x is not null)
113117
.Select(static (x, _) => x!);
114118

115-
// Reference equality on the CompilationUnitSyntax root: Roslyn keeps unchanged files'
116-
// syntax tree roots as the same object across incremental runs, so editing a noise
117-
// file leaves all other (file, compilation) pairs equal and skips re-emission.
119+
// A file's interceptors are produced by binding its call sites, which depend on the whole
120+
// compilation (the lambdas reference types/members defined in other files). So we recompute
121+
// when the compilation changes and gate the output on the value-equatable generated source
122+
// below — a comparer keyed on the file's own syntax would serve stale interceptors after a
123+
// cross-file edit.
118124
var filesWithCompilation = candidateFiles
119-
.Combine(context.CompilationProvider)
120-
.WithComparer(CompilationUnitAndCompilationComparer.Instance);
125+
.Combine(context.CompilationProvider);
121126

122127
// Source generators don't see each other's AddSource output, so SemanticModel can't bind
123128
// references to ExpressiveGenerator's synthesized [ExpressiveProperty] partials. Mirror
@@ -152,20 +157,46 @@ inv.Expression is MemberAccessExpressionSyntax &&
152157
.Collect()
153158
.WithComparer(SynthesizedSourceArrayComparer.Instance);
154159

155-
var filesWithCompilationAndSynth = filesWithCompilation
160+
var fileResults = filesWithCompilation
156161
.Combine(synthesizedSources)
157-
.WithComparer(FileAndSynthesizedSourcesComparer.Instance);
162+
.Select(static (pair, ct) =>
163+
ComputeFileInterceptors(pair.Left.Left, pair.Left.Right, pair.Right, ct));
164+
165+
// Generated interceptor source is value-data: re-emitted only when a file's interceptors
166+
// actually change. Implementation output — interceptors are only needed for the real build
167+
// (the user's code type-checks against the stub methods), so this stays off the live path.
168+
var interceptorSources = fileResults
169+
.Select(static (r, _) => r.Sources)
170+
.WithTrackingName(InterceptorSourcesTrackingName);
171+
context.RegisterImplementationSourceOutput(interceptorSources,
172+
static (spc, sources) =>
173+
{
174+
foreach (var source in sources.AsImmutableArray)
175+
spc.AddSource(source.HintName, SourceText.From(source.Text, Encoding.UTF8));
176+
});
158177

159-
context.RegisterSourceOutput(filesWithCompilationAndSynth,
160-
static (spc, pair) =>
178+
// Diagnostics flow live (real syntax-tree locations); recomputed each run, never stale.
179+
var interceptorDiagnostics = fileResults.Select(static (r, _) => r.Diagnostics);
180+
context.RegisterImplementationSourceOutput(interceptorDiagnostics,
181+
static (spc, diagnostics) =>
161182
{
162-
// Keeps its existing caching; collector is just the shared emit path, flushed inline.
163-
var output = new GeneratorOutputContext(spc.CancellationToken);
164-
ProcessFileAndEmit(pair.Left.Left, pair.Left.Right, pair.Right, output);
165-
output.FlushTo(spc);
183+
foreach (var diagnostic in diagnostics)
184+
spc.ReportDiagnostic(diagnostic);
166185
});
167186
}
168187

188+
private static (EquatableArray<GeneratedSource> Sources, ImmutableArray<Diagnostic> Diagnostics)
189+
ComputeFileInterceptors(
190+
CompilationUnitSyntax unit,
191+
Compilation compilation,
192+
ImmutableArray<(string HintName, string Source)> synthesizedSources,
193+
CancellationToken cancellationToken)
194+
{
195+
var output = new GeneratorOutputContext(cancellationToken);
196+
ProcessFileAndEmit(unit, compilation, synthesizedSources, output);
197+
return (output.Sources, output.Diagnostics);
198+
}
199+
169200
private static void ProcessFileAndEmit(
170201
CompilationUnitSyntax unit,
171202
Compilation compilation,
@@ -858,58 +889,4 @@ private static string ResolveTypeFqn(ITypeSymbol type, Dictionary<ITypeSymbol, s
858889

859890
return type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
860891
}
861-
862-
/// <summary>
863-
/// Reference equality on the CompilationUnitSyntax root, ignoring Compilation.
864-
/// Roslyn keeps unchanged files' syntax tree roots as the same object across incremental
865-
/// runs, so noise-file edits leave untouched (file, compilation) pairs equal and skip
866-
/// re-emission — O(1) incremental cost.
867-
/// </summary>
868-
private sealed class CompilationUnitAndCompilationComparer
869-
: IEqualityComparer<(CompilationUnitSyntax Left, Compilation Right)>
870-
{
871-
public readonly static CompilationUnitAndCompilationComparer Instance
872-
= new CompilationUnitAndCompilationComparer();
873-
874-
private CompilationUnitAndCompilationComparer() { }
875-
876-
public bool Equals(
877-
(CompilationUnitSyntax Left, Compilation Right) x,
878-
(CompilationUnitSyntax Left, Compilation Right) y)
879-
=> ReferenceEquals(x.Left, y.Left);
880-
881-
public int GetHashCode((CompilationUnitSyntax Left, Compilation Right) obj)
882-
=> System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj.Left);
883-
}
884-
885-
// ── FileAndSynthesizedSourcesComparer ──────────────────────────────────────
886-
887-
/// <summary>
888-
/// ANDs <see cref="CompilationUnitAndCompilationComparer"/> with sequence equality on the
889-
/// synthesized array — editing an [ExpressiveProperty] attribute correctly re-emits all
890-
/// files since synthesized binding can affect any file's lambdas.
891-
/// </summary>
892-
private sealed class FileAndSynthesizedSourcesComparer
893-
: IEqualityComparer<((CompilationUnitSyntax Left, Compilation Right) Left, ImmutableArray<(string HintName, string Source)> Right)>
894-
{
895-
public readonly static FileAndSynthesizedSourcesComparer Instance = new();
896-
897-
private FileAndSynthesizedSourcesComparer() { }
898-
899-
public bool Equals(
900-
((CompilationUnitSyntax Left, Compilation Right) Left, ImmutableArray<(string HintName, string Source)> Right) x,
901-
((CompilationUnitSyntax Left, Compilation Right) Left, ImmutableArray<(string HintName, string Source)> Right) y)
902-
=> CompilationUnitAndCompilationComparer.Instance.Equals(x.Left, y.Left)
903-
&& SynthesizedSourceArrayComparer.Instance.Equals(x.Right, y.Right);
904-
905-
public int GetHashCode(
906-
((CompilationUnitSyntax Left, Compilation Right) Left, ImmutableArray<(string HintName, string Source)> Right) obj)
907-
{
908-
unchecked
909-
{
910-
return CompilationUnitAndCompilationComparer.Instance.GetHashCode(obj.Left) * 31
911-
+ SynthesizedSourceArrayComparer.Instance.GetHashCode(obj.Right);
912-
}
913-
}
914-
}
915892
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
using System.Linq;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.VisualStudio.TestTools.UnitTesting;
5+
using ExpressiveSharp.Generator.Tests.Infrastructure;
6+
7+
namespace ExpressiveSharp.Generator.Tests.PolyfillInterceptorGenerator;
8+
9+
// Guards that a cross-file edit doesn't serve stale interceptors (and that an unrelated edit
10+
// doesn't churn them). An interceptor is built by binding a file's call sites, which depend on
11+
// the whole compilation — not just that file's syntax.
12+
[TestClass]
13+
public class IncrementalCachingTests : GeneratorTestBase
14+
{
15+
private const string QuerySource = """
16+
using System;
17+
using System.Linq.Expressions;
18+
using ExpressiveSharp;
19+
namespace TestNs
20+
{
21+
class Q
22+
{
23+
public void Run()
24+
{
25+
Expression<Func<Order, long>> e = ExpressionPolyfill.Create<Func<Order, long>>(o => o.Value);
26+
}
27+
}
28+
}
29+
""";
30+
31+
private const string ModelsInt = "namespace TestNs { class Order { public int Value { get; set; } } }";
32+
private const string ModelsLong = "namespace TestNs { class Order { public long Value { get; set; } } }";
33+
private const string ModelsIntWithUnrelated =
34+
"namespace TestNs { class Order { public int Value { get; set; } public int Other { get; set; } } }";
35+
36+
private static GeneratorDriver CreateDriver(CSharpParseOptions parseOptions) => CSharpGeneratorDriver
37+
.Create(
38+
new[] { new global::ExpressiveSharp.Generator.PolyfillInterceptorGenerator().AsSourceGenerator() },
39+
driverOptions: new GeneratorDriverOptions(default, trackIncrementalGeneratorSteps: true))
40+
.WithUpdatedParseOptions(parseOptions);
41+
42+
private static string InterceptorText(GeneratorDriverRunResult run) =>
43+
string.Join("\n", run.GeneratedTrees.Select(t => t.GetText().ToString()));
44+
45+
[TestMethod]
46+
public void CrossFileTypeChange_IncrementalOutputMatchesFreshRun()
47+
{
48+
var c1 = CreateCompilation(new[] { QuerySource, ModelsInt });
49+
var parseOptions = (CSharpParseOptions)c1.SyntaxTrees.First().Options;
50+
var modelsV1 = c1.SyntaxTrees.First(t => t.ToString().Contains("class Order"));
51+
var modelsV2 = CSharpSyntaxTree.ParseText(ModelsLong, parseOptions, modelsV1.FilePath);
52+
var c2 = c1.ReplaceSyntaxTree(modelsV1, modelsV2);
53+
54+
var driver = CreateDriver(parseOptions).RunGenerators(c1).RunGenerators(c2);
55+
var incremental = InterceptorText(driver.GetRunResult());
56+
57+
var fresh = InterceptorText(CreateDriver(parseOptions).RunGenerators(c2).GetRunResult());
58+
59+
Assert.AreEqual(fresh, incremental,
60+
"After a cross-file type change, interceptor output must match a from-scratch run.");
61+
Assert.IsFalse(incremental.Contains("Convert"),
62+
"With Value typed as long, no widening Convert should remain in the interceptor.");
63+
}
64+
65+
[TestMethod]
66+
public void UnrelatedCrossFileEdit_DoesNotInvalidateInterceptor()
67+
{
68+
var c1 = CreateCompilation(new[] { QuerySource, ModelsInt });
69+
var parseOptions = (CSharpParseOptions)c1.SyntaxTrees.First().Options;
70+
var modelsV1 = c1.SyntaxTrees.First(t => t.ToString().Contains("class Order"));
71+
var modelsV1b = CSharpSyntaxTree.ParseText(ModelsIntWithUnrelated, parseOptions, modelsV1.FilePath);
72+
var c2 = c1.ReplaceSyntaxTree(modelsV1, modelsV1b);
73+
74+
var driver = CreateDriver(parseOptions).RunGenerators(c1);
75+
var text1 = InterceptorText(driver.GetRunResult());
76+
77+
driver = driver.RunGenerators(c2);
78+
var run2 = driver.GetRunResult();
79+
80+
Assert.AreEqual(text1, InterceptorText(run2),
81+
"Precondition: the unrelated edit should not change the interceptor text.");
82+
83+
var steps = run2.Results
84+
.Single()
85+
.TrackedSteps[global::ExpressiveSharp.Generator.PolyfillInterceptorGenerator.InterceptorSourcesTrackingName];
86+
87+
// Cached (input unchanged) or Unchanged (re-ran, equal value) both mean "not re-emitted";
88+
// only New/Modified would cause downstream churn.
89+
foreach (var step in steps)
90+
{
91+
foreach (var output in step.Outputs)
92+
{
93+
Assert.IsTrue(
94+
output.Reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged,
95+
$"Expected the interceptor source to be gated (Cached/Unchanged) but was {output.Reason}.");
96+
}
97+
}
98+
}
99+
}

0 commit comments

Comments
 (0)