Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/ExpressiveSharp.Generator/Emitter/ReflectionFieldCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ public ReflectionFieldCache(Dictionary<ITypeSymbol, string> 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)
{
Expand Down
46 changes: 46 additions & 0 deletions src/ExpressiveSharp.Generator/Emitter/TypeFqnResolver.cs
Original file line number Diff line number Diff line change
@@ -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<int, Anon>, IEnumerable<Anon>, 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<ITypeSymbol, string> 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);
}
}
107 changes: 64 additions & 43 deletions src/ExpressiveSharp.Generator/PolyfillInterceptorGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, Anon>,
// IEnumerable<Anon>, 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<ITypeSymbol, string> typeAliases, List<string> 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;
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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<int, Anon>) 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<string>();
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
Expand All @@ -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))
Expand Down Expand Up @@ -857,36 +909,5 @@ private static bool IsExpressiveQueryable(INamedTypeSymbol type)
/// For <c>IEnumerable&lt;Customer&gt;</c> where Customer→T1, returns <c>IEnumerable&lt;T1&gt;</c>.
/// </summary>
private static string ResolveTypeFqn(ITypeSymbol type, Dictionary<ITypeSymbol, string> 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);
}
Original file line number Diff line number Diff line change
@@ -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<Order> 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));
}
}
Loading