diff --git a/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs b/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs
index 244bcd8..643a716 100644
--- a/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs
+++ b/benchmarks/ExpressiveSharp.Benchmarks/PolyfillGeneratorBenchmarks.cs
@@ -288,3 +288,129 @@ public static class BenchHelper { public static string Describe(int id) => $""#{
return sources;
}
}
+
+///
+/// Cold-build cost in a codebase where query files are mixed with ordinary non-lambda
+/// member-access calls (sb.Append(x), list.Add(x), Console.WriteLine)
+/// — the shape of real production code.
+///
+/// Fixed 10 files × 5 real call sites per file;
+/// controls how many non-intercept invocations sit alongside them. The gap between
+/// NoiseInvocationsPerFile = 0 and higher values reflects how well the generator
+/// filters out invocations it cannot intercept.
+///
+///
+[MemoryDiagnoser]
+public class PolyfillColdBuildWithNoiseBenchmarks
+{
+ [Params(0, 25, 100)]
+ public int NoiseInvocationsPerFile { get; set; }
+
+ private const int FileCount = 10;
+ private const int CallSitesPerFile = 5;
+
+ private Compilation _compilation = null!;
+ private GeneratorDriver _warmedDriver = null!;
+ private Compilation _callSiteFileModified = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ var sources = BuildSources(FileCount, CallSitesPerFile, NoiseInvocationsPerFile);
+ _compilation = CreateCompilation(sources);
+
+ _warmedDriver = CSharpGeneratorDriver
+ .Create(new PolyfillInterceptorGenerator())
+ .RunGenerators(_compilation);
+
+ // Index FileCount = last query file (has CallSitesPerFile real call sites + noise)
+ var callSiteTree = _compilation.SyntaxTrees.ElementAt(FileCount);
+ _callSiteFileModified = _compilation.ReplaceSyntaxTree(
+ callSiteTree,
+ callSiteTree.WithChangedText(SourceText.From(callSiteTree.GetText() + "\n// bench-edit")));
+ }
+
+ // ── Generator-only (no compilation rebuild) ──────────────────────────────
+
+ [Benchmark(Baseline = true)]
+ public GeneratorDriver Cold()
+ => CSharpGeneratorDriver
+ .Create(new PolyfillInterceptorGenerator())
+ .RunGenerators(_compilation);
+
+ [Benchmark]
+ public GeneratorDriver Incremental_EditCallSiteFile()
+ => _warmedDriver.RunGenerators(_callSiteFileModified);
+
+ // ── End-to-end (generator + Roslyn compilation rebuild) ──────────────────
+
+ [Benchmark]
+ public GeneratorDriver Cold_E2E()
+ => CSharpGeneratorDriver
+ .Create(new PolyfillInterceptorGenerator())
+ .RunGeneratorsAndUpdateCompilation(_compilation, out _, out _);
+
+ [Benchmark]
+ public GeneratorDriver Incremental_EditCallSiteFile_E2E()
+ => _warmedDriver.RunGeneratorsAndUpdateCompilation(_callSiteFileModified, out _, out _);
+
+ private static Compilation CreateCompilation(IReadOnlyList sources)
+ {
+ var refs = Basic.Reference.Assemblies.Net100.References.All.ToList();
+ refs.Add(MetadataReference.CreateFromFile(typeof(ExpressiveAttribute).Assembly.Location));
+ return CSharpCompilation.Create(
+ "PolyfillColdBuildNoiseBench",
+ sources.Select((s, i) => CSharpSyntaxTree.ParseText(s, path: $"PolyfillNoise{i}.cs")),
+ refs,
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+ }
+
+ private static IReadOnlyList BuildSources(int fileCount, int sitesPerFile, int noisePerFile)
+ {
+ // index 0: entity
+ var sources = new List
+ {
+ @"using System.Linq; using ExpressiveSharp; namespace PolyfillBench;
+public class BenchEntity { public int Id { get; set; } public string Name { get; set; } = """"; }"
+ };
+
+ // indices 1..fileCount: query files (real call sites + noise invocations)
+ var sb = new StringBuilder();
+ for (var f = 0; f < fileCount; f++)
+ {
+ sb.Clear();
+ sb.AppendLine("using System; using System.Linq; using System.Text; using System.Collections.Generic; using ExpressiveSharp;");
+ sb.AppendLine("namespace PolyfillBench;");
+ sb.AppendLine($"public static class NoisyQueries{f} {{");
+
+ for (var i = 0; i < sitesPerFile; i++)
+ sb.AppendLine($" public static IQueryable Q{f}_{i}(IExpressiveQueryable q) => q.Select(x => x.Id + {f * 100 + i});");
+
+ if (noisePerFile > 0)
+ {
+ sb.AppendLine($" public static string Helper{f}() {{");
+ sb.AppendLine(" var buf = new StringBuilder();");
+ sb.AppendLine(" var items = new List();");
+ for (var n = 0; n < noisePerFile; n++)
+ {
+ // Rotate through common call shapes so the sample isn't pathologically uniform.
+ switch (n % 5)
+ {
+ case 0: sb.AppendLine($" buf.Append(\"a{n}\");"); break;
+ case 1: sb.AppendLine($" buf.AppendLine(\"line{n}\");"); break;
+ case 2: sb.AppendLine($" items.Add({n});"); break;
+ case 3: sb.AppendLine($" Console.WriteLine(\"msg{n}\");"); break;
+ case 4: sb.AppendLine($" buf.Insert(0, {n}.ToString());"); break;
+ }
+ }
+ sb.AppendLine(" return buf.ToString();");
+ sb.AppendLine(" }");
+ }
+
+ sb.AppendLine("}");
+ sources.Add(sb.ToString());
+ }
+
+ return sources;
+ }
+}
diff --git a/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs b/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs
index e211d71..3d02c3f 100644
--- a/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs
+++ b/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs
@@ -105,12 +105,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
{
ct.ThrowIfCancellationRequested();
var unit = (CompilationUnitSyntax)ctx.Node;
- // Quick syntactic pre-filter: any member-access invocation with ≥1 argument?
+ // Every intercepted call site (Polyfill.Create + IExpressiveQueryable stubs)
+ // takes at least one lambda argument. Files with no lambda-bearing invocations
+ // can't contain anything we'd intercept, so drop them before semantic binding.
foreach (var descendant in unit.DescendantNodes())
{
if (descendant is InvocationExpressionSyntax inv &&
inv.Expression is MemberAccessExpressionSyntax &&
- inv.ArgumentList.Arguments.Count > 0)
+ HasLambdaArgument(inv))
return unit;
}
return null;
@@ -162,8 +164,8 @@ private static void ProcessFileAndEmit(
foreach (var descendant in unit.DescendantNodes())
{
if (descendant is not InvocationExpressionSyntax inv) continue;
- if (inv.Expression is not MemberAccessExpressionSyntax) continue;
- if (inv.ArgumentList.Arguments.Count == 0) continue;
+ if (inv.Expression is not MemberAccessExpressionSyntax ma) continue;
+ if (!HasLambdaArgument(inv)) continue;
ct.ThrowIfCancellationRequested();
try
@@ -171,13 +173,25 @@ private static void ProcessFileAndEmit(
// Use the METHOD NAME token position for stable, unique naming.
// Using the invocation's span-start would collide in chained calls
// (e.g. query.AsExpressive().Where(…) — both start at 'query').
- var ma = (MemberAccessExpressionSyntax)inv.Expression;
var nameLineSpan = ma.Name.GetLocation().GetLineSpan();
var line = nameLineSpan.StartLinePosition.Line;
var col = nameLineSpan.StartLinePosition.Character;
- var methodCode = TryEmitPolyfill(inv, model, line, col, fileTag, spc)
- ?? TryEmit(inv, model, line, col, fileTag, spc);
+ // Exclusive dispatch: ExpressionPolyfill.Create takes the polyfill path,
+ // everything else takes the IExpressiveQueryable path.
+ if (model.GetSymbolInfo(inv).Symbol is not IMethodSymbol method) continue;
+
+ string? methodCode;
+ if (ma.Name.Identifier.Text == PolyfillMethodName &&
+ method.ContainingType?.ToDisplayString() == PolyfillTypeName)
+ {
+ methodCode = TryEmitPolyfill(inv, model, method, line, col, fileTag, spc);
+ }
+ else
+ {
+ methodCode = TryEmit(inv, ma, model, method, line, col, fileTag, spc);
+ }
+
if (methodCode is null) continue;
methodCodes.Add(methodCode);
@@ -271,19 +285,13 @@ private static string GetFileTag(string sourcePath)
///
private static string? TryEmitPolyfill(InvocationExpressionSyntax inv,
SemanticModel model,
+ IMethodSymbol method,
int line,
int col,
string fileTag,
SourceProductionContext spc)
{
- if (model.GetSymbolInfo(inv).Symbol is not IMethodSymbol method)
- return null;
-
- // Must be ExpressionPolyfill.Create(...)
- if (method.Name != PolyfillMethodName)
- return null;
- if (method.ContainingType?.ToDisplayString() != PolyfillTypeName)
- return null;
+ // Caller guarantees method is ExpressionPolyfill.Create.
if (method.TypeArguments.Length != 1)
return null;
@@ -352,14 +360,14 @@ private static string GetFileTag(string sourcePath)
// ── Per-invocation dispatch (IExpressiveQueryable) ───────────────────────
private static string? TryEmit(InvocationExpressionSyntax inv,
+ MemberAccessExpressionSyntax ma,
SemanticModel model,
+ IMethodSymbol method,
int line,
int col,
string fileTag,
SourceProductionContext spc)
{
- var ma = (MemberAccessExpressionSyntax)inv.Expression;
-
// Receiver must be or implement IExpressiveQueryable.
if (model.GetTypeInfo(ma.Expression).Type is not INamedTypeSymbol receiverType)
return null;
@@ -368,9 +376,6 @@ private static string GetFileTag(string sourcePath)
if (!IsExpressiveQueryable(receiverType))
return null;
- if (model.GetSymbolInfo(inv).Symbol is not IMethodSymbol method)
- return null;
-
// The stub convention: at least one non-receiver parameter must be a Func<> delegate,
// not an Expression>. This distinguishes user/library IExpressiveQueryable stubs
// from regular IQueryable extension methods.
@@ -520,6 +525,17 @@ private static bool IsAnonymousType(ITypeSymbol type)
// ── Formatting helpers ───────────────────────────────────────────────────
+ private static bool HasLambdaArgument(InvocationExpressionSyntax inv)
+ {
+ var args = inv.ArgumentList.Arguments;
+ for (var i = 0; i < args.Count; i++)
+ {
+ if (args[i].Expression is LambdaExpressionSyntax)
+ return true;
+ }
+ return false;
+ }
+
///
/// Produces a unique, stable interceptor method name based on the call site's
/// source file hash + method-name-token position (line/col).