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
14 changes: 14 additions & 0 deletions .claude/docs/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,17 @@ Product projects live in `src/`, tests in `tests/`, and benchmarks in `benchmark
Source-generated and reflection metadata feed the same execution path. Roslyn compatibility projects (`*.Roslyn414`, `*.Roslyn44`, `*.Roslyn47`) link shared sources from their base project; account for those variants when changing generators or analyzers.

Generators, analyzers, code fixers and build tasks run inside consumers' compilers, so their behaviour must not depend on the build machine's culture (sv-SE renders negative numbers with U+2212, de-DE uses decimal commas, tr-TR lowercases `I` to `ı`). `Directory.Build.targets` applies `TUNITINT001` and `eng/BuildTimeGlobalization.globalconfig` (CA1304/CA1305/CA1309/CA1310/CA1311 as errors) to those projects; format and parse with `CultureInfo.InvariantCulture` and compare strings with `StringComparison.Ordinal`. `TUNITINT001` cannot see a number that is already typed `object` or an unconstrained generic before it is formatted (other than Roslyn's `TypedConstant.Value`); convert such values with `ToInvariantString()` or `Convert.ToString(value, CultureInfo.InvariantCulture)`. For a justified exception, such as text that is meant to follow the user's culture, wrap the line in `#pragma warning disable TUNITINT001` / `restore` with a comment explaining why; a false positive caused by a Roslyn or SDK change can be downgraded with `dotnet_diagnostic.TUNITINT001.severity` in `eng/BuildTimeGlobalization.globalconfig`.

## Whole-tuple method inference

Generated and reflection discovery must preserve the same whole-row boundary before inferring method types. `WholeTupleInferenceTests` snapshots the shared `_7638/WholeTupleInferenceTests.cs` fixtures; `Issue7638Tests` checks counts and failure outcomes in reflection and Native AOT modes in CI.

| Rule | Generator | Runtime |
| --- | --- | --- |
| A tuple first argument with omitted optional, params, or collecting-array tails requires invariant constructed structure and consistent repeated generic bindings | Whole-row matcher in `TestMetadataGenerator` | `TupleHelper.MatchesOpenTuple`, used by `DataSourceHelpers`, `TestDataRowUnwrapper`, and `DataUnwrapper` |
| A nested row that does not match the whole first tuple spreads into separate columns | Row-type inference falls back to ordinary column inference | Type-aware unwrapping spreads the row |
| Explicit method types supplement inferred whole-row instantiations, deduplicating identical types | `InferTypesFromMethodDataSourceRowType` | `ReflectionTestDataCollector.InferTypesFromMethodDataSource` |
| Async source declarations and outer Func, derived TestDataRow, and inner Func wrappers normalize before inference | `GetMethodDataSourceRowType` | `MethodDataSourceAttribute.GetDeclaredRowType` and `TestDataRowUnwrapper.GetInnerDataType` |
| Ordinary spread columns retain last-column inference, including arrays and nullable values | `ProcessTypeForGenerics` | `MapGenericParameters` |

Sole-tuple element conversion and reconstruction retain their existing behavior. Analyzer false positives for these whole-row shapes are tracked separately in #7622; matcher maintainability is tracked in #7588.
67 changes: 60 additions & 7 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 / integration-tests

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 (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 / modularpipeline (sv-SE)

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 (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 / AOT (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 / 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 (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 @@ -7288,7 +7288,7 @@
return namedType.TupleElements.Select(element => element.Type).ToArray();
}

if (namedType is not { Name: "Tuple", Arity: >= 1 and <= 8, ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } })
if (namedType is not { Name: "Tuple" or "ValueTuple", Arity: >= 1 and <= 8, ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } })
{
return null;
}
Expand All @@ -7303,6 +7303,35 @@
: null;
}

// A tuple passed as one argument must match its complete constructed shape. In particular,
// repeated generic parameters bind consistently and closed tuple arguments stay invariant.
private static bool MatchesOpenRowType(ITypeSymbol parameter, ITypeSymbol row,
Dictionary<ITypeSymbol, ITypeSymbol> bindings)
{
if (parameter is ITypeParameterSymbol)
{
if (bindings.TryGetValue(parameter, out var bound))
return SymbolEqualityComparer.Default.Equals(bound, row);
bindings.Add(parameter, row);
return true;
}

if (!CodeGenerationHelpers.ContainsTypeParameter(parameter))
return SymbolEqualityComparer.Default.Equals(parameter, row);

if (parameter is IArrayTypeSymbol array && row is IArrayTypeSymbol rowArray)
return array.Rank == rowArray.Rank && MatchesOpenRowType(array.ElementType, rowArray.ElementType, bindings);

if (parameter is not INamedTypeSymbol { IsGenericType: true } named
|| row is not INamedTypeSymbol { IsGenericType: true } rowNamed
|| !SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, rowNamed.OriginalDefinition))
return false;

for (var i = 0; i < named.TypeArguments.Length; i++)
if (!MatchesOpenRowType(named.TypeArguments[i], rowNamed.TypeArguments[i], bindings)) return false;
return true;
}

private static ITypeSymbol[]? InferTypesFromMethodDataSourceRowType(TestMethodMetadata testMethod, AttributeData mdsAttr, ITypeSymbol[]? classTypes = null)
{
if (mdsAttr.ConstructorArguments.Length == 0)
Expand Down Expand Up @@ -7346,14 +7375,33 @@
var genericParams = testMethod.MethodSymbol.TypeParameters;
var genericParamMap = new Dictionary<string, ITypeSymbol>();

if (rowType is INamedTypeSymbol { IsTupleType: true } tupleType
&& !(testMethodParams.Length == 1 && testMethodParams[0].Type.IsTupleType))
Dictionary<ITypeSymbol, ITypeSymbol>? wholeTupleBindings = null;
var receivesWholeTuple = testMethodParams.Length > 1
&& GetTupleElementTypes(CloseClassType(testMethodParams[0].Type)) is not null
&& testMethodParams.Skip(1).All(parameter => parameter.IsOptional || parameter.IsParams
|| SymbolEqualityComparer.Default.Equals(parameter, testMethodParams[^1]) && parameter.CollectsTrailingArguments())
&& MatchesOpenRowType(CloseClassType(testMethodParams[0].Type), rowType,
wholeTupleBindings = new Dictionary<ITypeSymbol, ITypeSymbol>(SymbolEqualityComparer.Default));
var receivesBareReferenceTuple = testMethodParams.Length == 1
&& testMethodParams[0].Type is ITypeParameterSymbol
&& rowType is INamedTypeSymbol { IsTupleType: false, IsValueType: false }
&& GetTupleElementTypes(rowType) is not null
&& testMethod.MethodAttributes.Any(static attribute => attribute.AttributeClass?.IsOrInherits("global::TUnit.Core.GenerateGenericTestAttribute") is true);

if (receivesWholeTuple)
{
foreach (var binding in wholeTupleBindings!)
if (binding.Key is ITypeParameterSymbol parameter && genericParams.Contains(parameter))
genericParamMap[parameter.Name] = binding.Value;
}
else if (GetTupleElementTypes(rowType) is { } tupleElements
&& !receivesBareReferenceTuple
&& !(testMethodParams.Length == 1 && GetTupleElementTypes(testMethodParams[0].Type) is not null))
{
// A tuple row is spread across the test method's parameters
var tupleElements = tupleType.TupleElements;
for (var i = 0; i < testMethodParams.Length && i < tupleElements.Length; i++)
{
ProcessTypeForGenerics(CloseClassType(testMethodParams[i].Type), tupleElements[i].Type, genericParams, genericParamMap);
ProcessTypeForGenerics(CloseClassType(testMethodParams[i].Type), tupleElements[i], genericParams, genericParamMap);
}
}
else if (testMethodParams.Length > 0)
Expand Down Expand Up @@ -7797,9 +7845,14 @@
// Func inside it. Anything nested deeper reaches the test as a value (e.g. a Func<int> row
// from Func<Func<int>>), so inference must see that value's type too.
type = Unwrap(type, funcType);
if (TryUnwrap(type, testDataRowType, out var rowData))
// Runtime unwrapping uses ITestDataRow, including records derived from TestDataRow<T>.
for (var candidate = type as INamedTypeSymbol; candidate is not null; candidate = candidate.BaseType)
{
type = Unwrap(rowData, funcType);
if (TryUnwrap(candidate, testDataRowType, out var rowData))
{
type = Unwrap(rowData, funcType);
break;
}
}

if (type.SpecialType == SpecialType.System_Object
Expand Down
4 changes: 3 additions & 1 deletion src/TUnit.Core/Helpers/DataSourceHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,9 @@ public static T InvokeIfFunc<T>(object? value)
// Keep a matching first tuple parameter whole, including when later parameters
// are omitted. A nested row of a different type still spreads into columns.
if (TupleHelper.IsTupleType(expectedTypes[0])
&& (expectedTypes.Length == 1 || expectedTypes[0].IsInstanceOfType(item)))
&& (expectedTypes.Length == 1 || expectedTypes[0].ContainsGenericParameters
&& TupleHelper.MatchesOpenTuple(expectedTypes[0], item.GetType())
|| expectedTypes[0].IsInstanceOfType(item)))
{
if (RequiresFlattenedTupleBinding(item))
{
Expand Down
17 changes: 12 additions & 5 deletions src/TUnit.Core/Helpers/TestDataRowUnwrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,19 @@ public static bool IsTestDataRowType(Type? type)
}

/// <summary>
/// Gets the inner data type from a <see cref="TestDataRow{T}"/> type.
/// Gets the inner data type from a <see cref="TestDataRow{T}"/> type or a derived record.
/// </summary>
public static Type? GetInnerDataType(Type testDataRowType)
{
if (!IsTestDataRowType(testDataRowType))
for (var current = testDataRowType; current is not null; current = current.BaseType)
{
return null;
if (IsTestDataRowType(current))
{
return current.GetGenericArguments()[0];
}
}

return testDataRowType.GetGenericArguments()[0];
return null;
}

/// <summary>
Expand All @@ -83,7 +86,11 @@ public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapArray(object
}

// Check if the data is a tuple that should be expanded
if (DataSourceHelpers.IsTuple(data) && (expectedTupleType is null || !expectedTupleType.IsInstanceOfType(data)))
if (DataSourceHelpers.IsTuple(data)
&& !(expectedTupleType is not null
&& (expectedTupleType.IsInstanceOfType(data)
|| expectedTupleType.ContainsGenericParameters
&& TupleHelper.MatchesOpenTuple(expectedTupleType, data!.GetType()))))
{
return (data.ToObjectArray(), metadata);
}
Expand Down
46 changes: 46 additions & 0 deletions src/TUnit.Core/Helpers/TupleHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,52 @@ namespace TUnit.Core.Helpers;
public static class TupleHelper
{

// Process-lifetime cache: one entry per distinct parameter/source type pair.
private static readonly ConcurrentDictionary<(Type Parameter, Type Row), bool> OpenTupleMatches = new();

// Open tuple types cannot use IsInstanceOfType. Match their complete shape so a nested
// row with separate columns is not mistaken for the first tuple argument.
internal static bool MatchesOpenTuple(Type parameter, Type row) =>
OpenTupleMatches.GetOrAdd((parameter, row), static pair => MatchesType(pair.Parameter, pair.Row, new Dictionary<Type, Type>()));

private static bool MatchesType(Type parameter, Type row, Dictionary<Type, Type> bindings)
{
if (parameter.IsGenericParameter)
{
if (bindings.TryGetValue(parameter, out var bound)) return bound == row;
bindings.Add(parameter, row);
return true;
}

if (!parameter.ContainsGenericParameters)
{
// Tuple type arguments are invariant. Element assignability alone does not make
// the constructed outer tuple assignable. Mirrors generator MatchesOpenRowType.
return parameter == row;
}

if (parameter.IsArray)
{
return row.IsArray && parameter.GetArrayRank() == row.GetArrayRank()
&& MatchesType(parameter.GetElementType()!, row.GetElementType()!, bindings);
}

if (!parameter.IsGenericType || !row.IsGenericType
|| parameter.GetGenericTypeDefinition() != row.GetGenericTypeDefinition())
{
return false;
}

var parameters = parameter.GetGenericArguments();
var arguments = row.GetGenericArguments();
for (var i = 0; i < parameters.Length; i++)
{
if (!MatchesType(parameters[i], arguments[i], bindings)) return false;
}

return true;
}

#if NET
/// <summary>
/// Checks if a type is a tuple type (ValueTuple or Tuple)
Expand Down
77 changes: 58 additions & 19 deletions src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2917,13 +2917,13 @@ private static bool IsSingleDimensionalZeroBasedArray(Type type)
/// <summary>
/// Mirrors the generator's <c>InferTypesFromMethodDataSource</c>. It only reads attributes whose first
/// constructor argument is the member name (<c>[MethodDataSource("Rows")]</c> and
/// <c>[MethodDataSource&lt;T&gt;("Rows")]</c>), looks for a static parameterless method of that name
/// declared on the test class (or on <c>T</c>), and requires its declared return type to be
/// <c>IEnumerable&lt;Func&lt;(...)&gt;&gt;</c>. Each tuple element is matched against the data parameter
/// <c>[MethodDataSource&lt;T&gt;("Rows")]</c>), resolves the member using the same lookup as source invocation
/// on the test class (or on <c>T</c>), and requires its declared type to be
/// a supported row source, including asynchronous wrappers. Each tuple element is matched against the data parameter
/// at the same position, including type arguments nested in generic parameter types. The caller passes
/// the parameters without any CancellationToken, as the generator's <c>GetDataParameters</c> does.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Reflection mode requires dynamic access to data source methods")]
[UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "Reflection mode requires dynamic access to data source members")]
private static Type[]? InferTypesFromMethodDataSource(
Type testClass,
MethodDataSourceAttribute methodDataSource,
Expand All @@ -2941,26 +2941,42 @@ private static bool IsSingleDimensionalZeroBasedArray(Type type)

// [MethodDataSource<T>("Rows")] reads Rows from T, not from the test class
var providerType = methodDataSource.ClassProvidingDataSource ?? testClass;
var dataMethod = providerType.GetMethod(
methodDataSource.MethodNameProvidingDataSource,
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.DeclaredOnly,
binder: null,
Type.EmptyTypes,
modifiers: null);
Type? memberType;
try
{
memberType = methodDataSource.GetDeclaredMemberType(providerType);
}
catch (AmbiguousMatchException)
{
// Invocation reports the invalid source later; inference must not abort assembly discovery.
return null;
}

if (dataMethod?.ReturnType is not { IsGenericType: true } returnType
|| returnType.GetGenericTypeDefinition() != typeof(IEnumerable<>)
|| returnType.GetGenericArguments()[0] is not { IsGenericType: true } funcType
|| funcType.GetGenericTypeDefinition() != typeof(Func<>)
|| GetValueTupleElementTypes(funcType.GetGenericArguments()[0]) is not { } tupleElements)
// Normalize the same asynchronous and sequence wrappers as runtime row reading before
// supplementing explicit method types. Retain the bounded outer/inner Func unwrapping.
if (memberType is null || MethodDataSourceAttribute.GetDeclaredRowType(memberType) is not { } rowType
|| GetTupleElementTypes(rowType) is not { } tupleElements)
{
return null;
}

var typeArguments = new Type?[genericParameterCount];
for (var i = 0; i < parameters.Length && i < tupleElements.Length; i++)
var receivesWholeTuple = parameters.Length > 0
&& ((GetTupleElementTypes(parameters[0].ParameterType) is not null
&& parameters.Skip(1).All(parameter => parameter.IsOptional
|| parameter.IsDefined(typeof(ParamArrayAttribute), false)
|| IsParamCollection(parameter)
|| (parameter == parameters[^1] && IsSingleDimensionalZeroBasedArray(parameter.ParameterType)))
&& (parameters.Length == 1 || TupleHelper.MatchesOpenTuple(parameters[0].ParameterType, rowType)))
|| (parameters.Length == 1 && parameters[0].ParameterType.IsGenericParameter && !rowType.IsValueType));
if (receivesWholeTuple)
{
MapGenericParameters(parameters[0].ParameterType, rowType, typeArguments);
}
else
{
MapGenericParameters(parameters[i].ParameterType, tupleElements[i], typeArguments);
for (var i = 0; i < parameters.Length && i < tupleElements.Length; i++)
MapGenericParameters(parameters[i].ParameterType, tupleElements[i], typeArguments);
}

foreach (var typeArgument in typeArguments)
Expand Down Expand Up @@ -3001,8 +3017,18 @@ private static bool ReadsMemberOfOpenClass(IDataSourceAttribute dataSource) =>
private static Type[]? InferClassTypeArgumentsFromOwnMemberRowType(Type genericTypeDefinition, MethodDataSourceAttribute methodDataSource, out ConstructorInfo? constructor)
{
constructor = null;
if (methodDataSource.GetDeclaredMemberType(genericTypeDefinition) is not { } memberType
|| MethodDataSourceAttribute.GetDeclaredRowType(memberType) is not { } rowType)
Type? memberType;
try
{
memberType = methodDataSource.GetDeclaredMemberType(genericTypeDefinition);
}
catch (AmbiguousMatchException)
{
// Leave the invalid source to invocation instead of failing discovery for the assembly.
return null;
}

if (memberType is null || MethodDataSourceAttribute.GetDeclaredRowType(memberType) is not { } rowType)
{
return null;
}
Expand Down Expand Up @@ -3128,6 +3154,19 @@ private static void MapGenericParameters(Type parameterType, Type actualType, Ty
return;
}

// Ordinary spread columns retain last-column inference through nullable values and arrays.
if (Nullable.GetUnderlyingType(parameterType) is { } nullableParameter
&& actualType.IsValueType && Nullable.GetUnderlyingType(actualType) is null)
{
MapGenericParameters(nullableParameter, actualType, typeArguments);
return;
}
if (parameterType.IsArray && actualType.IsArray && parameterType.GetArrayRank() == actualType.GetArrayRank())
{
MapGenericParameters(parameterType.GetElementType()!, actualType.GetElementType()!, typeArguments);
return;
}

if (parameterType.IsGenericType
&& actualType.IsGenericType
&& parameterType.GetGenericTypeDefinition() == actualType.GetGenericTypeDefinition())
Expand Down
Loading
Loading