From e45d081c27a12cf425c04c818aae9891e0aa0507 Mon Sep 17 00:00:00 2001 From: Koen Date: Sun, 2 Aug 2026 00:13:55 +0000 Subject: [PATCH] support anonymous types nested in generic interceptor signatures --- .../Emitter/ExpressionTreeEmitter.cs | 2 +- .../Emitter/ReflectionFieldCache.cs | 4 +- .../Emitter/TypeFqnResolver.cs | 46 ++++++++ .../PolyfillInterceptorGenerator.cs | 107 +++++++++++------- .../GeneratedOutputCompilationTests.cs | 70 ++++++++++++ 5 files changed, 183 insertions(+), 46 deletions(-) create mode 100644 src/ExpressiveSharp.Generator/Emitter/TypeFqnResolver.cs create mode 100644 tests/ExpressiveSharp.Generator.Tests/PolyfillInterceptorGenerator/GeneratedOutputCompilationTests.cs diff --git a/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs b/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs index aea48924..b2f3fc47 100644 --- a/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs +++ b/src/ExpressiveSharp.Generator/Emitter/ExpressionTreeEmitter.cs @@ -75,7 +75,7 @@ public void RegisterTypeAlias(ITypeSymbol type, string alias) => _typeAliases[type] = alias; private string ResolveTypeFqn(ITypeSymbol type) - => _typeAliases.TryGetValue(type, out var alias) ? alias : type.ToDisplayString(_fqnFormat); + => _fieldCache.ResolveTypeFqn(type); public EmitResult Emit( SyntaxNode bodySyntax, diff --git a/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs b/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs index d68cba9b..4f688217 100644 --- a/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs +++ b/src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs @@ -14,8 +14,8 @@ public ReflectionFieldCache(Dictionary typeAliases) _typeAliases = typeAliases; } - private string ResolveTypeFqn(ITypeSymbol type) - => _typeAliases.TryGetValue(type, out var alias) ? alias : type.ToDisplayString(_fullyQualifiedFormat); + internal string ResolveTypeFqn(ITypeSymbol type) + => TypeFqnResolver.Resolve(type, _typeAliases); public string EnsurePropertyInfo(IPropertySymbol property) { diff --git a/src/ExpressiveSharp.Generator/Emitter/TypeFqnResolver.cs b/src/ExpressiveSharp.Generator/Emitter/TypeFqnResolver.cs new file mode 100644 index 00000000..a4b7eaf8 --- /dev/null +++ b/src/ExpressiveSharp.Generator/Emitter/TypeFqnResolver.cs @@ -0,0 +1,46 @@ +using Microsoft.CodeAnalysis; + +namespace ExpressiveSharp.Generator.Emitter; + +internal static class TypeFqnResolver +{ + private static readonly SymbolDisplayFormat _fullyQualifiedFormat = + SymbolDisplayFormat.FullyQualifiedFormat; + + // Aliases can sit at any nesting depth (IGrouping, IEnumerable, Anon[]), so + // arguments resolve recursively; the rebuild stays gated on an actual substitution so + // alias-free types keep their plain display string. + internal static string Resolve(ITypeSymbol type, Dictionary typeAliases) + { + if (typeAliases.TryGetValue(type, out var alias)) + return alias; + + if (type is IArrayTypeSymbol array) + { + var elementFqn = Resolve(array.ElementType, typeAliases); + if (elementFqn != array.ElementType.ToDisplayString(_fullyQualifiedFormat)) + return elementFqn + "[" + new string(',', array.Rank - 1) + "]"; + } + + if (type is INamedTypeSymbol named && named.TypeArguments.Length > 0) + { + var anyResolved = false; + var resolvedArgs = new string[named.TypeArguments.Length]; + for (var i = 0; i < named.TypeArguments.Length; i++) + { + resolvedArgs[i] = Resolve(named.TypeArguments[i], typeAliases); + anyResolved |= resolvedArgs[i] != named.TypeArguments[i].ToDisplayString(_fullyQualifiedFormat); + } + if (anyResolved) + { + var openType = named.ConstructedFrom.ToDisplayString(_fullyQualifiedFormat); + var idx = openType.LastIndexOf('<'); + if (idx >= 0) + openType = openType.Substring(0, idx); + return openType + "<" + string.Join(", ", resolvedArgs) + ">"; + } + } + + return type.ToDisplayString(_fullyQualifiedFormat); + } +} diff --git a/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs b/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs index bbd2391e..97590a5b 100644 --- a/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs +++ b/src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs @@ -545,6 +545,40 @@ private static string GetFileTag(string sourcePath) private static bool IsAnonymousType(ITypeSymbol type) => type is INamedTypeSymbol { IsAnonymousType: true }; + // Anonymous types have no nameable form at ANY nesting depth (IGrouping, + // IEnumerable, Anon[]) — such signatures must go through the generic-parameter path. + private static bool ContainsAnonymousType(ITypeSymbol type) + => type switch + { + INamedTypeSymbol { IsAnonymousType: true } => true, + INamedTypeSymbol named => named.TypeArguments.Any(ContainsAnonymousType), + IArrayTypeSymbol array => ContainsAnonymousType(array.ElementType), + _ => false, + }; + + private static void AddNestedAnonymousTypeParams( + ITypeSymbol type, Dictionary typeAliases, List typeParamNames) + { + switch (type) + { + case INamedTypeSymbol { IsAnonymousType: true } anon: + if (!typeAliases.ContainsKey(anon)) + { + var paramName = $"T{typeParamNames.Count}"; + typeParamNames.Add(paramName); + typeAliases[anon] = paramName; + } + break; + case INamedTypeSymbol named: + foreach (var argument in named.TypeArguments) + AddNestedAnonymousTypeParams(argument, typeAliases, typeParamNames); + break; + case IArrayTypeSymbol array: + AddNestedAnonymousTypeParams(array.ElementType, typeAliases, typeParamNames); + break; + } + } + private static bool ContainsInvalidOperation(IOperation op) { if (op is IInvalidOperation) return true; @@ -585,19 +619,19 @@ private static string MethodId(string op, string fileTag, int line, int col) bool single = funcParamIndices.Count == 1; - var hasAnyAnon = elemSym.IsAnonymousType; + var hasAnyAnon = ContainsAnonymousType(elemSym); for (int i = 0; i < funcParamIndices.Count; i++) { var fta = ((INamedTypeSymbol)method.Parameters[funcParamIndices[i]].Type).TypeArguments; for (int j = 0; j < fta.Length; j++) - hasAnyAnon = hasAnyAnon || IsAnonymousType(fta[j]); + hasAnyAnon = hasAnyAnon || ContainsAnonymousType(fta[j]); } // Non-Func params can also be anonymous (e.g. AggregateBy seed). for (int i = 0; i < method.Parameters.Length; i++) { if (!funcParamIndices.Contains(i)) - hasAnyAnon = hasAnyAnon || IsAnonymousType(method.Parameters[i].Type); + hasAnyAnon = hasAnyAnon || ContainsAnonymousType(method.Parameters[i].Type); } var isRewritableReturn = method.ReturnType is INamedTypeSymbol rqType @@ -607,7 +641,7 @@ private static string MethodId(string op, string fileTag, int line, int col) if (isRewritableReturn) { returnElemType = ((INamedTypeSymbol)method.ReturnType).TypeArguments[0]; - hasAnyAnon = hasAnyAnon || IsAnonymousType(returnElemType); + hasAnyAnon = hasAnyAnon || ContainsAnonymousType(returnElemType); } var scalarReturnFqn = method.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); @@ -627,11 +661,29 @@ private static string MethodId(string op, string fileTag, int line, int col) if (hasAnyAnon) { - // All method type args become generic params (T0, T1, …) so the C# compiler can infer - // anonymous types — they have no nameable form in the interceptor signature. - var typeParamNames = new string[methodTypeArgs.Length]; + // Method type args become generic params (T0, T1, …) so the C# compiler can infer + // anonymous types — they have no nameable form in the interceptor signature. An arg + // that merely CONTAINS an anonymous type (IGrouping) resolves structurally + // instead, with each nested anonymous type getting its own generic param — that keeps + // those params inferable from the signature and nameable inside the body + // (e.g. MakeGenericMethod(typeof(T0)) for g.Count()). + var typeParamNames = new List(); + var positionRefs = new string[methodTypeArgs.Length]; for (int i = 0; i < methodTypeArgs.Length; i++) - typeParamNames[i] = $"T{i}"; + { + var arg = methodTypeArgs[i]; + if (!IsAnonymousType(arg) && ContainsAnonymousType(arg)) + { + AddNestedAnonymousTypeParams(arg, typeAliases, typeParamNames); + positionRefs[i] = ResolveTypeFqn(arg, typeAliases); + } + else + { + var paramName = $"T{typeParamNames.Count}"; + typeParamNames.Add(paramName); + positionRefs[i] = paramName; + } + } typeParams = $"<{string.Join(", ", typeParamNames)}>"; // The unsubstituted method type parameter symbols carry per-position identity @@ -641,13 +693,13 @@ private static string MethodId(string op, string fileTag, int line, int col) // sees substituted parameter symbols) can still resolve anonymous return types // and element types into Tn aliases. for (int i = 0; i < method.TypeParameters.Length; i++) - typeAliases[method.TypeParameters[i]] = typeParamNames[i]; + typeAliases[method.TypeParameters[i]] = positionRefs[i]; if (!typeAliases.ContainsKey(elemSym)) - typeAliases[elemSym] = typeParamNames[0]; + typeAliases[elemSym] = positionRefs[0]; for (int i = 0; i < methodTypeArgs.Length; i++) { if (!typeAliases.ContainsKey(methodTypeArgs[i])) - typeAliases[methodTypeArgs[i]] = typeParamNames[i]; + typeAliases[methodTypeArgs[i]] = positionRefs[i]; } if (typeAliases.TryGetValue(elemSym, out var ep)) @@ -857,36 +909,5 @@ private static bool IsExpressiveQueryable(INamedTypeSymbol type) /// For IEnumerable<Customer> where Customer→T1, returns IEnumerable<T1>. /// private static string ResolveTypeFqn(ITypeSymbol type, Dictionary typeAliases) - { - if (typeAliases.TryGetValue(type, out var alias)) - return alias; - - if (type is INamedTypeSymbol named && named.TypeArguments.Length > 0) - { - bool anyResolved = false; - var resolvedArgs = new string[named.TypeArguments.Length]; - for (int i = 0; i < named.TypeArguments.Length; i++) - { - if (typeAliases.TryGetValue(named.TypeArguments[i], out var argAlias)) - { - resolvedArgs[i] = argAlias; - anyResolved = true; - } - else - { - resolvedArgs[i] = named.TypeArguments[i].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - } - } - if (anyResolved) - { - var openType = named.ConstructedFrom.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var idx = openType.LastIndexOf('<'); - if (idx >= 0) - openType = openType.Substring(0, idx); - return openType + "<" + string.Join(", ", resolvedArgs) + ">"; - } - } - - return type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - } + => Emitter.TypeFqnResolver.Resolve(type, typeAliases); } diff --git a/tests/ExpressiveSharp.Generator.Tests/PolyfillInterceptorGenerator/GeneratedOutputCompilationTests.cs b/tests/ExpressiveSharp.Generator.Tests/PolyfillInterceptorGenerator/GeneratedOutputCompilationTests.cs new file mode 100644 index 00000000..3cbafc79 --- /dev/null +++ b/tests/ExpressiveSharp.Generator.Tests/PolyfillInterceptorGenerator/GeneratedOutputCompilationTests.cs @@ -0,0 +1,70 @@ +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ExpressiveSharp.Generator.Tests.Infrastructure; + +namespace ExpressiveSharp.Generator.Tests.PolyfillInterceptorGenerator; + +[TestClass] +public class GeneratedOutputCompilationTests : GeneratorTestBase +{ + private (GeneratorDriverRunResult Result, Compilation Output) RunInterceptorGenerator(Compilation compilation) + { + var subject = new global::ExpressiveSharp.Generator.PolyfillInterceptorGenerator(); + GeneratorDriver driver = CSharpGeneratorDriver + .Create(subject) + .WithUpdatedParseOptions((CSharpParseOptions)compilation.SyntaxTrees.First().Options); + + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _); + var result = driver.GetRunResult(); + + foreach (var tree in result.GeneratedTrees) + { + TestContext.WriteLine($"Generated: {tree.FilePath}"); + TestContext.WriteLine(tree.GetText().ToString()); + } + + return (result, outputCompilation); + } + + private static string FormatErrors(Compilation output) => + string.Join("\n", output.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.ToString())); + + [TestMethod] + public void AnonymousType_NestedTwoGenericLevels_InterceptorCompiles() + { + var source = + """ + using ExpressiveSharp; + + namespace TestNs + { + class Order { public string Tag { get; set; } } + class TestClass + { + public void Run(System.Linq.IQueryable query) + { + query.AsExpressive() + .Select(o => new { o.Tag }) + .GroupBy(a => a.Tag) + .Select(g => g.Count()) + .ToList(); + } + } + } + """; + + var (result, output) = RunInterceptorGenerator(CreateCompilation(source)); + + Assert.AreEqual(1, result.GeneratedTrees.Length, "Expected the interceptor file to be generated."); + Assert.IsFalse(result.Diagnostics.Any(d => d.Id == "EXP0010"), + "No call site should be dropped: " + string.Join("; ", result.Diagnostics)); + + var errors = output.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + Assert.AreEqual(0, errors.Count, + "Interceptor output must compile. Errors:\n" + FormatErrors(output)); + } +}