Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Harden handling for generic type arguments in source generator #61145

Merged
merged 7 commits into from
Mar 31, 2025
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 2 additions & 2 deletions src/OpenApi/gen/XmlCommentGenerator.Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public sealed partial class XmlCommentGenerator
if (DocumentationCommentId.GetFirstSymbolForDeclarationId(name, compilation) is ISymbol symbol &&
// Only include symbols that are declared in the application assembly or are
// accessible from the application assembly.
(SymbolEqualityComparer.Default.Equals(symbol.ContainingAssembly, input.Compilation.Assembly) || symbol.IsAccessibleType()) &&
(SymbolEqualityComparer.Default.Equals(symbol.ContainingAssembly, compilation.Assembly) || symbol.IsAccessibleType()) &&
// Skip static classes that are just containers for members with annotations
// since they cannot be instantiated.
symbol is not INamedTypeSymbol { TypeKind: TypeKind.Class, IsStatic: true })
Expand All @@ -104,7 +104,7 @@ public sealed partial class XmlCommentGenerator
{
var memberKey = symbol switch
{
IMethodSymbol methodSymbol => MemberKey.FromMethodSymbol(methodSymbol, input.Compilation),
IMethodSymbol methodSymbol => MemberKey.FromMethodSymbol(methodSymbol),
IPropertySymbol propertySymbol => MemberKey.FromPropertySymbol(propertySymbol),
INamedTypeSymbol typeSymbol => MemberKey.FromTypeSymbol(typeSymbol),
_ => null
Expand Down
241 changes: 166 additions & 75 deletions src/OpenApi/gen/XmlComments/MemberKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;

namespace Microsoft.AspNetCore.OpenApi.SourceGenerators.Xml;
Expand All @@ -14,14 +14,19 @@ internal sealed record MemberKey(
MemberType MemberKind,
string? Name,
string? ReturnType,
string[]? Parameters) : IEquatable<MemberKey>
List<string>? Parameters) : IEquatable<MemberKey>
{
private static readonly SymbolDisplayFormat _typeKeyFormat = new(
private static readonly SymbolDisplayFormat _withTypeParametersFormat = new(
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included,
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters);

public static MemberKey FromMethodSymbol(IMethodSymbol method, Compilation compilation)
private static readonly SymbolDisplayFormat _withoutTypeParametersFormat = new(
globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included,
typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
genericsOptions: SymbolDisplayGenericsOptions.None);

public static MemberKey? FromMethodSymbol(IMethodSymbol method)
{
string returnType;
if (method.ReturnsVoid)
Expand All @@ -30,108 +35,194 @@ public static MemberKey FromMethodSymbol(IMethodSymbol method, Compilation compi
}
else
{
// Handle Task/ValueTask for async methods
var actualReturnType = method.ReturnType;
if (method.IsAsync && actualReturnType is INamedTypeSymbol namedType)
if (actualReturnType.TypeKind == TypeKind.TypeParameter)
{
if (namedType.TypeArguments.Length > 0)
{
actualReturnType = namedType.TypeArguments[0];
}
else
{
actualReturnType = compilation.GetSpecialType(SpecialType.System_Void);
}
returnType = "typeof(object)";
}
else if (TryGetFormattedTypeName(actualReturnType, out var formattedReturnType))
{
returnType = $"typeof({formattedReturnType})";
}
else
{
return null;
}

returnType = actualReturnType.TypeKind == TypeKind.TypeParameter
? "typeof(object)"
: $"typeof({ReplaceGenericArguments(actualReturnType.ToDisplayString(_typeKeyFormat))})";
}

// Handle extension methods by skipping the 'this' parameter
var parameters = method.Parameters
.Where(p => !p.IsThis)
.Select(p =>
List<string> parameters = [];
foreach (var parameter in method.Parameters)
{
if (parameter.IsThis)
{
continue;
}

if (parameter.Type.TypeKind == TypeKind.TypeParameter)
{
parameters.Add("typeof(object)");
}
else if (parameter.IsParams && parameter.Type is IArrayTypeSymbol arrayType)
{
if (p.Type.TypeKind == TypeKind.TypeParameter)
if (TryGetFormattedTypeName(arrayType.ElementType, out var formattedArrayType))
{
return "typeof(object)";
parameters.Add($"typeof({formattedArrayType}[])");
}

// For params arrays, use the array type
if (p.IsParams && p.Type is IArrayTypeSymbol arrayType)
else
{
return $"typeof({ReplaceGenericArguments(arrayType.ToDisplayString(_typeKeyFormat))})";
return null;
}
}
else if (TryGetFormattedTypeName(parameter.Type, out var formattedParameterType))
{
parameters.Add($"typeof({formattedParameterType})");
}
else
{
return null;
}
}

return $"typeof({ReplaceGenericArguments(p.Type.ToDisplayString(_typeKeyFormat))})";
})
.ToArray();

// For generic methods, use the containing type with generic parameters
var declaringType = method.ContainingType;
var typeDisplay = declaringType.ToDisplayString(_typeKeyFormat);

// If the method is in a generic type, we need to handle the type parameters
if (declaringType.IsGenericType)
if (TryGetFormattedTypeName(method.ContainingType, out var formattedDeclaringType))
{
typeDisplay = ReplaceGenericArguments(typeDisplay);
return new MemberKey(
$"typeof({formattedDeclaringType})",
MemberType.Method,
method.MetadataName, // Use MetadataName to match runtime MethodInfo.Name
returnType,
parameters);
}

return new MemberKey(
$"typeof({typeDisplay})",
MemberType.Method,
method.MetadataName, // Use MetadataName to match runtime MethodInfo.Name
returnType,
parameters);
return null;
}

public static MemberKey FromPropertySymbol(IPropertySymbol property)
public static MemberKey? FromPropertySymbol(IPropertySymbol property)
{
return new MemberKey(
$"typeof({ReplaceGenericArguments(property.ContainingType.ToDisplayString(_typeKeyFormat))})",
MemberType.Property,
property.Name,
null,
null);
if (TryGetFormattedTypeName(property.ContainingType, out var typeName))
{
return new MemberKey(
$"typeof({typeName})",
MemberType.Property,
property.Name,
null,
null);
}
return null;
}

public static MemberKey FromTypeSymbol(INamedTypeSymbol type)
public static MemberKey? FromTypeSymbol(INamedTypeSymbol type)
{
return new MemberKey(
$"typeof({ReplaceGenericArguments(type.ToDisplayString(_typeKeyFormat))})",
MemberType.Type,
null,
null,
null);
if (TryGetFormattedTypeName(type, out var typeName))
{
return new MemberKey(
$"typeof({typeName})",
MemberType.Type,
null,
null,
null);
}
return null;
}

/// Supports replacing generic type arguments to support use of open
/// generics in `typeof` expressions for the declaring type.
private static string ReplaceGenericArguments(string typeName)
private static bool TryGetFormattedTypeName(ITypeSymbol typeSymbol, [NotNullWhen(true)] out string? typeName, bool isNestedCall = false)
{
if (typeSymbol is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullableType)
{
typeName = typeSymbol.ToDisplayString(_withTypeParametersFormat);
return true;
}

// Handle tuples specially since they are represented as generic
// ValueTuple types and trigger the logic for handling generics in
// nested values.
if (typeSymbol is INamedTypeSymbol { IsTupleType: true } namedType)
{
return TryHandleTupleType(namedType, out typeName);
}

if (typeSymbol is INamedTypeSymbol { IsGenericType: true } genericType)
{
// If any of the type arguments are type parameters, then they have not
// been substituted for a concrete type and we need to model them as open
// generics if possible to avoid emitting a type with type parameters that
// cannot be used in a typeof expression.
var hasTypeParameters = genericType.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter);
var typeNameWithoutGenerics = genericType.ToDisplayString(_withoutTypeParametersFormat);

if (!hasTypeParameters)
{
var typeArgStrings = new List<string>();
var allArgumentsResolved = true;

// Loop through each type argument to handle nested generics.
foreach (var typeArg in genericType.TypeArguments)
{
if (TryGetFormattedTypeName(typeArg, out var argTypeName, isNestedCall: true))
{
typeArgStrings.Add(argTypeName);
}
else
{
typeName = null;
return false;
}
}

if (allArgumentsResolved)
{
typeName = $"{typeNameWithoutGenerics}<{string.Join(", ", typeArgStrings)}>";
return true;
}
}
else
{
if (isNestedCall)
{
// If this is a nested call, we can't use open generics so there's no way
// for us to emit a member key. Return false and skip over this type in the code
// generation.
typeName = null;
return false;
}

// If we got here, we can successfully emit a member key for the open generic type.
var genericArgumentsCount = genericType.TypeArguments.Length;
var openGenericsPlaceholder = "<" + new string(',', genericArgumentsCount - 1) + ">";

typeName = typeNameWithoutGenerics + openGenericsPlaceholder;
return true;
}
}

typeName = typeSymbol.ToDisplayString(_withTypeParametersFormat);
return true;
}

private static bool TryHandleTupleType(INamedTypeSymbol tupleType, [NotNullWhen(true)] out string? typeName)
{
var stack = new Stack<int>();
var result = new StringBuilder(typeName);
for (var i = 0; i < result.Length; i++)
List<string> elementTypes = [];
foreach (var element in tupleType.TupleElements)
{
if (result[i] == '<')
if (element.Type.TypeKind == TypeKind.TypeParameter)
{
stack.Push(i);
elementTypes.Add("object");
}
else if (result[i] == '>' && stack.Count > 0)
else
{
var start = stack.Pop();
// Replace everything between < and > with empty strings separated by commas
var segment = result.ToString(start + 1, i - start - 1);
var commaCount = segment.Count(c => c == ',');
var replacement = new string(',', commaCount);
result.Remove(start + 1, i - start - 1);
result.Insert(start + 1, replacement);
i = start + replacement.Length + 1;
// Process each tuple element and handle nested generics
if (!TryGetFormattedTypeName(element.Type, out var elementTypeName, isNestedCall: true))
{
typeName = null;
return false;
}
elementTypes.Add(elementTypeName);
}
}
return result.ToString();

typeName = $"global::System.ValueTuple<{string.Join(", ", elementTypes)}>";
return true;
}
}

Expand Down
Loading