Skip to content
Merged
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
7 changes: 3 additions & 4 deletions src/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,10 @@ public static string GenerateTypeInfo(ITypeSymbol typeSymbol)
// Fall through to regular typeof() handling
}

if (typeSymbol is IArrayTypeSymbol && ContainsTypeParameter(typeSymbol))
if (typeSymbol is IArrayTypeSymbol array && ContainsTypeParameter(typeSymbol))
{
// An array of a type parameter (e.g. `params T[]`) has no TypeInfo variant, and typeof(T[])
// does not compile outside T's scope. Erase it to object, as the parameter's Type already is.
return "new global::TUnit.Core.ConcreteType(typeof(object))";
// Preserve the recursive signature without constructing an open runtime array type.
return $"new global::TUnit.Core.ArrayType({GenerateTypeInfo(array.ElementType)}, {array.Rank.ToString(CultureInfo.InvariantCulture)})";
}

// Regular concrete type (including fully closed generic types like List<int>)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ public SemanticModel GetSemanticModel(SyntaxTree syntaxTree)
return _semanticModelCache.GetOrAdd(syntaxTree, tree => compilation.GetSemanticModel(tree));
}

/// <summary>Whether this attribute is retained in generated test metadata.</summary>
public bool CanWriteAttribute(AttributeData attributeData) =>
attributeData.AttributeClass is { } attributeClass
&& IsTUnitRelatedAttribute(attributeClass)
&& !attributeData.ConstructorArguments.Any(arg =>
arg is { Kind: TypedConstantKind.Type, Value: ITypeSymbol typeSymbol }
&& typeSymbol.IsCompilerGeneratedType());

public void WriteAttributes(ICodeWriter sourceCodeWriter,
IEnumerable<AttributeData> attributeDatas)
{
Expand All @@ -39,21 +47,7 @@ public void WriteAttributes(ICodeWriter sourceCodeWriter,
// Filter out attributes that we can write
foreach (var attributeData in attributeDatas)
{
if (attributeData.AttributeClass is null)
{
continue;
}

// Only include attributes that are, inherit from, or implement a TUnit type
if (!IsTUnitRelatedAttribute(attributeData.AttributeClass))
{
continue;
}

// Skip attributes with compiler-generated type arguments
if (attributeData.ConstructorArguments.Any(arg =>
arg is { Kind: TypedConstantKind.Type, Value: ITypeSymbol typeSymbol } &&
typeSymbol.IsCompilerGeneratedType()))
if (!CanWriteAttribute(attributeData))
{
continue;
}
Expand Down
169 changes: 169 additions & 0 deletions src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
private const string InstanceMethodDataSourceAttributeName = "global::TUnit.Core.InstanceMethodDataSourceAttribute";

private static readonly DiagnosticDescriptor GenerationErrorDescriptor = new(
GeneratorDiagnosticIds.TestMetadataGenerationFailed,

Check warning on line 30 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (tr-TR)

'TUNIT0999' kuralını içeren çözümleyici projesi için çözümleyici yayını izlemeyi etkinleştir (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)

Check warning on line 30 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (fr-FR)

Activer le suivi de version d'analyseur pour le projet d'analyseur contenant la règle 'TUNIT0999' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)

Check warning on line 30 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / doc-snippets

Enable analyzer release tracking for the analyzer project containing rule 'TUNIT0999' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)

Check warning on line 30 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / doc-snippets

Enable analyzer release tracking for the analyzer project containing rule 'TUNIT0999' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)

Check warning on line 30 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (windows-latest)

Enable analyzer release tracking for the analyzer project containing rule 'TUNIT0999' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)

Check warning on line 30 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / AOT (windows-latest)

Enable analyzer release tracking for the analyzer project containing rule 'TUNIT0999' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)

Check warning on line 30 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

Enable analyzer release tracking for the analyzer project containing rule 'TUNIT0999' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)

Check warning on line 30 in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs

View workflow job for this annotation

GitHub Actions / modularpipeline (macos-latest)

Enable analyzer release tracking for the analyzer project containing rule 'TUNIT0999' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
"Source Generation Error",
"Failed to generate test metadata for {0}.{1}: {2}",
"TUnit",
Expand Down Expand Up @@ -680,6 +680,146 @@
}
}

private static IEnumerable<AttributeData> GetConstraintViolationAttributes(TestMethodMetadata testMethod) =>
GetConstraintViolationInheritedAttributes(GetOverrideChain(testMethod.MethodSymbol))
Comment thread
thomhurst marked this conversation as resolved.
Resolved
.Concat(GetConstraintViolationInheritedAttributes(testMethod.TypeSymbol.GetSelfAndBaseTypes()))
.Concat(testMethod.TypeSymbol.ContainingAssembly.GetAttributes())
.Where(attribute => !DataSourceAttributeHelper.IsDataSourceAttribute(attribute.AttributeClass));

private static IEnumerable<IMethodSymbol> GetOverrideChain(IMethodSymbol method)
{
for (IMethodSymbol? current = method; current is not null; current = current.OverriddenMethod)
{
yield return current;
}
}

private static IEnumerable<AttributeData> GetConstraintViolationInheritedAttributes(IEnumerable<ISymbol> scopes)
{
// Match GetCustomAttributes(inherit: true) for both types and overridden methods.
// This is used by both reflection discovery
// and the inaccessible-attribute accessor. Occurrence indices must see the same list.
var seen = new HashSet<INamedTypeSymbol>(SymbolEqualityComparer.Default);
var isDeclaredScope = true;
foreach (var current in scopes)
{
var declared = current.GetAttributes();
foreach (var attribute in declared)
{
if (attribute.AttributeClass is not { } attributeType)
{
continue;
}

var usage = attributeType.GetAttributes()
.FirstOrDefault(candidate => candidate.AttributeClass?.ToDisplayString() == "System.AttributeUsageAttribute");
var inherited = true;
var allowMultiple = false;
if (usage is not null)
{
foreach (var argument in usage.NamedArguments)
{
if (argument.Key == "Inherited") inherited = (bool)argument.Value.Value!;
if (argument.Key == "AllowMultiple") allowMultiple = (bool)argument.Value.Value!;
}
}

if (isDeclaredScope
|| (inherited && (allowMultiple || !seen.Contains(attributeType))))
{
yield return attribute;
}
}

foreach (var attribute in declared)
{
if (attribute.AttributeClass is { } attributeType) seen.Add(attributeType);
}
isDeclaredScope = false;
}
}

private static void WriteConstraintViolationAttributes(CodeWriter writer, TestMethodMetadata testMethod, AttributeData[] attributes)
{
var compilation = testMethod.CompilationContext.Compilation;
var occurrences = new Dictionary<INamedTypeSymbol, int>(SymbolEqualityComparer.Default);
var first = true;
foreach (var attribute in attributes)
{
if (!first)
{
writer.AppendLine(",");
}
first = false;
var attributeType = attribute.AttributeClass!;
// Reflection matches generic definitions, so count all constructed forms together.
var attributeDefinition = attributeType.OriginalDefinition;
occurrences.TryGetValue(attributeDefinition, out var occurrence);
occurrences[attributeDefinition] = occurrence + 1;
if (CanConstructConstraintViolationAttribute(attribute, compilation))
{
testMethod.CompilationContext.AttributeWriter.WriteAttribute(writer, attribute);
}
else
{
// Private custom filter attributes cannot be named by the top-level source. The
// existing metadata accessor preserves their actual constructor logic and values.
var metadataName = attributeType.MetadataName;
for (var containing = attributeType.ContainingType; containing is not null; containing = containing.ContainingType)
{
metadataName = containing.MetadataName + "+" + metadataName;
}
if (!attributeType.ContainingNamespace.IsGlobalNamespace)
{
metadataName = attributeType.ContainingNamespace.ToDisplayString() + "." + metadataName;
}
writer.Append($"global::System.Linq.Enumerable.ElementAtOrDefault(global::System.Linq.Enumerable.Where(global::TUnit.Core.Extensions.MetadataExtensions.GetCustomAttributes(__mm_0), static attribute => (attribute.GetType().IsGenericType ? attribute.GetType().GetGenericTypeDefinition() : attribute.GetType()).FullName == {SymbolDisplay.FormatLiteral(metadataName, quote: true)}), {occurrence.ToString(CultureInfo.InvariantCulture)})!");
}
}
}

private static bool CanConstructConstraintViolationAttribute(AttributeData attribute, Compilation compilation) =>
attribute.AttributeClass is { } attributeType
&& compilation.IsSymbolAccessibleWithin(attributeType, compilation.Assembly)
&& attribute.AttributeConstructor is { } constructor
&& compilation.IsSymbolAccessibleWithin(constructor, compilation.Assembly)
&& attribute.ConstructorArguments.All(argument => CanAccessAttributeArgument(argument, compilation))
&& attribute.NamedArguments.All(argument => CanAccessAttributeArgument(argument.Value, compilation));

private static bool CanAccessAttributeArgument(TypedConstant argument, Compilation compilation)
{
if (argument.Type is { } argumentType
&& !compilation.IsSymbolAccessibleWithin(argumentType, compilation.Assembly))
{
return false;
}

if (argument is { Kind: TypedConstantKind.Type, Value: ITypeSymbol type })
{
return compilation.IsSymbolAccessibleWithin(type, compilation.Assembly);
}

return argument.Kind != TypedConstantKind.Array || argument.IsNull
|| argument.Values.All(value => CanAccessAttributeArgument(value, compilation));
}

private static bool AttributeRequiresUnsafeContext(AttributeData attribute) =>
attribute.ConstructorArguments.Any(RequiresUnsafeContext)
Comment thread
thomhurst marked this conversation as resolved.
Resolved
|| attribute.NamedArguments.Any(argument => RequiresUnsafeContext(argument.Value));

private static bool RequiresUnsafeContext(TypedConstant argument) =>
argument.Kind == TypedConstantKind.Array
? !argument.IsNull && argument.Values.Any(RequiresUnsafeContext)
: argument.Value is ITypeSymbol type && RequiresUnsafeContext(type);

private static bool RequiresUnsafeContext(ITypeSymbol type) => type switch
{
IPointerTypeSymbol => true,
IFunctionPointerTypeSymbol => true,
IArrayTypeSymbol array => RequiresUnsafeContext(array.ElementType),
_ => false
};

/// <summary>
/// Emits the test method's class type arguments that break the class's generic constraints. A
/// <c>TestEntry&lt;T&gt;</c> cannot be emitted for such a closed type, so the entry is registered
Expand All @@ -698,11 +838,40 @@
writer.AppendLine($"FilePath = {SymbolDisplay.FormatLiteral(testMethod.FilePath ?? "", quote: true)},");
writer.AppendLine($"LineNumber = {testMethod.LineNumber.ToString(CultureInfo.InvariantCulture)},");
writer.AppendLine("MethodMetadata = __mm_0,");
var attributes = GetConstraintViolationAttributes(testMethod)
.Where(testMethod.CompilationContext.AttributeWriter.CanWriteAttribute).ToArray();
var requiresUnsafe = attributes.Any(attribute =>
CanConstructConstraintViolationAttribute(attribute, testMethod.CompilationContext.Compilation)
&& AttributeRequiresUnsafeContext(attribute));
if (requiresUnsafe)
{
writer.AppendLine("AttributeFactory = __CreateConstraintViolationAttributes,");
}
else
{
writer.AppendLine("AttributeFactory = static () => global::System.Array.FindAll(new global::System.Attribute[]");
writer.AppendLine("{");
writer.Indent();
WriteConstraintViolationAttributes(writer, testMethod, attributes);
writer.Unindent();
writer.AppendLine("}, static attribute => attribute is not null),");
}
writer.AppendLine($"Messages = new string[] {{ {string.Join(", ", messages.Select(m => $"\"{EscapeString(m)}\""))} }},");
writer.Unindent();
writer.AppendLine("},");
writer.Unindent();
writer.AppendLine("};");

if (requiresUnsafe)
{
// Limit the unsafe context to attribute construction: mixed valid entries can contain awaits.
writer.AppendLine("private static unsafe global::System.Attribute[] __CreateConstraintViolationAttributes() => global::System.Array.FindAll(new global::System.Attribute[]");
writer.AppendLine("{");
writer.Indent();
WriteConstraintViolationAttributes(writer, testMethod, attributes);
writer.Unindent();
writer.AppendLine("}, static attribute => attribute is not null);");
}
}

/// <summary>
Expand Down
68 changes: 55 additions & 13 deletions src/TUnit.Core/Extensions/MetadataExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,7 @@ public static class MetadataExtensions

public static MethodInfo GetReflectionInfo(this MethodMetadata method)
{
// Optimize: Use for-loop instead of LINQ to reduce allocations
var paramTypes = new Type[method.Parameters.Length];
for (int i = 0; i < method.Parameters.Length; i++)
{
paramTypes[i] = method.Parameters[i].Type;
}
return GetMethodFromType(method.Type, method.Name, paramTypes)!;
return method.ReflectionInfo ?? GetMethodFromType(method.Type, method.Name, method.Parameters, method.GenericTypeCount);
}

public static IEnumerable<Attribute> GetCustomAttributes(this MethodMetadata method)
Expand All @@ -37,27 +31,34 @@ public static IEnumerable<Attribute> GetCustomAttributes(this MethodMetadata met
/// <summary>
/// Gets a method from the specified type with proper AOT attribution
/// </summary>
private static MethodInfo? GetMethodFromType(
private static MethodInfo GetMethodFromType(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] Type type,
string name,
Type[] parameters)
ParameterMetadata[] parameters,
int genericTypeCount)
{
// Optimize: Avoid LINQ Select in hot path - use manual parameter comparison
var methods = type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.FlattenHierarchy);

MethodInfo? soleCandidate = null;
var candidateCount = 0;
foreach (var method in methods)
{
if (method.Name != name)
if (method.Name != name || (method.IsGenericMethod ? method.GetGenericArguments().Length : 0) != genericTypeCount)
continue;

var methodParams = method.GetParameters();
if (methodParams.Length != parameters.Length)
continue;

soleCandidate = method;
candidateCount++;
bool parametersMatch = true;
for (int i = 0; i < parameters.Length; i++)
{
if (methodParams[i].ParameterType != parameters[i])
if (!(method.ContainsGenericParameters
? MatchesTypeInfo(methodParams[i].ParameterType, parameters[i].TypeInfo)
: methodParams[i].ParameterType == parameters[i].Type))
{
parametersMatch = false;
break;
Expand All @@ -68,7 +69,48 @@ public static IEnumerable<Attribute> GetCustomAttributes(this MethodMetadata met
return method;
}

return type.GetMethod(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.FlattenHierarchy)
?? throw new InvalidOperationException($"Method '{name}' with parameters {string.Join(", ", parameters.Select(p => p.Name))} not found in type '{type.FullName}'.");
// Older metadata can erase shapes such as T[] to object. Preserve its unambiguous
// lookup without allowing a name-only fallback to select the wrong overload.
if (candidateCount == 1)
{
return soleCandidate!;
}

throw new InvalidOperationException($"Method '{name}' with {genericTypeCount} generic parameter(s) and {parameters.Length} parameter(s) not found in type '{type.FullName}'.");
}

private static bool MatchesTypeInfo(Type type, TypeInfo typeInfo)
{
switch (typeInfo)
{
case ConcreteType concrete:
return type == concrete.Type;
case GenericParameter generic:
return type.IsGenericParameter && type.GenericParameterPosition == generic.Position
&& (type.DeclaringMethod is not null) == generic.IsMethodParameter;
case ArrayType array:
return type.IsArray && type.GetArrayRank() == array.Rank
&& MatchesTypeInfo(type.GetElementType()!, array.ElementType);
case ConstructedGeneric constructed:
if (!type.IsGenericType || type.GetGenericTypeDefinition() != constructed.GenericDefinition)
{
return false;
}
var arguments = type.GetGenericArguments();
if (arguments.Length != constructed.TypeArguments.Length)
{
return false;
}
for (var i = 0; i < arguments.Length; i++)
{
if (!MatchesTypeInfo(arguments[i], constructed.TypeArguments[i]))
{
return false;
}
}
return true;
default:
return false;
}
}
}
Loading
Loading