Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
59e8460
Preserve tuple inference and runtime element conversions (#7629)
thomhurst Nov 5, 2026
74a6771
Fix repeated tuple element inference for #7629
thomhurst Nov 5, 2026
ef301ff
Preserve repeated nullable tuple inference (#7629)
thomhurst Nov 5, 2026
2d62d58
Preserve nested tuple inference and constrain base matching (#7629)
thomhurst Nov 5, 2026
b12f8fa
Align tuple candidate inference during invocation (#7629)
thomhurst Nov 5, 2026
e7e1fc5
Preserve strict tuple candidates and spread inference for #7629
thomhurst Nov 5, 2026
acd19d8
Validate completed tuple inference candidates (#7629)
thomhurst Nov 5, 2026
5688db4
Fix constrained tuple candidates and singleton inference for #7629
thomhurst Nov 5, 2026
f1d24d3
Preserve wrapped and nested singleton tuple boundaries (#7629)
thomhurst Nov 5, 2026
b4158b4
Preserve wrapped singleton tuple boundaries for #7629
thomhurst Nov 5, 2026
9d4b91d
fix: retain tuple inference for interface and base runtime casts (#7629)
thomhurst Nov 5, 2026
0589abe
Fix deferred tuple array inference (#7629)
thomhurst Nov 5, 2026
6c32140
fix: preserve nested tuple candidate bindings (#7629)
thomhurst Nov 5, 2026
04db53d
Fix whole-tuple inference alongside explicit method types (#7629)
thomhurst Nov 5, 2026
b2fec36
docs: clarify tuple inference CI modes (#7629)
thomhurst Nov 5, 2026
b8056fd
Fix reference tuple inference with explicit method types (#7629)
thomhurst Nov 5, 2026
313e9a4
Fix bare reference tuple inference with explicit method types (#7629)
thomhurst Nov 5, 2026
c18b6c0
Fix direct tuple inference with explicit method types (#7629)
thomhurst Nov 5, 2026
2a1cc37
Fix wrapped tuple and spread column inference for #7629
thomhurst Nov 5, 2026
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
15 changes: 15 additions & 0 deletions .claude/docs/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,18 @@ 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`.

## Tuple method-type inference

The generator uses Roslyn symbols and cannot share the runtime matcher. Keep these policies aligned; `Issue7629Tests` runs the shared `_7629/TupleInferenceTests.cs` fixtures in reflection and Native AOT (source-generated) modes in CI. `TupleInferenceTests` also snapshots generated metadata and invocation.

| Rule | Generator | Runtime |
| --- | --- | --- |
| Whole tuple rows with omitted trailing parameters require invariant structure and consistent repeated bindings | `MatchesOpenRowType` without reconstruction | `TupleHelper.MatchesOpenTuple` / `MatchesType` |
| A sole tuple argument is reconstructed; closed elements may convert at invocation | `MatchesOpenRowType` tuple-element loop | `TestGenericTypeResolver.TryInferTypeMapping` tuple-element loop; invocation uses the same matcher |
| Reconstructed elements with no structural match defer casts to invocation and supply no bindings | `MatchesOpenRowType` with overwrite bindings | `TryInferTypeMapping` with overwrite bindings |
| Structural open elements (direct, nullable, array, matching generic) use the last repeated binding | `MatchesOpenRowType` with overwrite bindings | `TryInferTypeMapping` with overwrite bindings |
| Interface/base candidates retain tuple context but isolate bindings, reject conflicting repeated bindings through nested tuple arguments, and validate closed type arguments | `MatchesOpenRowType` candidate copies and completed-type conversion check | `TryInferTypeMapping` candidate copies and completed-type assignability |
| Ordinary spread columns retain ordinary inference, including matching-family tuple columns | `ProcessTypeForGenerics` | `TryInferTypeMapping` without tuple-element reconstruction; `ReflectionTestDataCollector.InferGenericTypeMapping` |

`ReflectionTestDataCollector` delegates reconstructed tuple inference to `TryInferTypeMapping`; its ordinary-parameter inference remains separate. Unmatched open elements (including object, interface, and base declarations) likewise require other elements to infer the method types before invocation can cast them. Closed reconstructed elements may fail during invocation if their runtime values cannot convert; inference intentionally does not reject them from their declared types alone. Nullable stripping in the generator tuple loop handles direct type-parameter binding; recursive calls receive the original elements so structural nullable handling remains intact.
165 changes: 160 additions & 5 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 (ar-SA)

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

View workflow job for this annotation

GitHub Actions / AOT (windows-latest)

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

View workflow job for this annotation

GitHub Actions / modularpipeline (ubuntu-latest)

"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,137 @@
: null;
}

private static bool MatchesOpenRowType(ITypeSymbol parameter, ITypeSymbol row, Compilation compilation,
Dictionary<ITypeSymbol, ITypeSymbol> bindings, bool reconstructTupleElements, bool overwriteBindings = false)
{
if (parameter is ITypeParameterSymbol)
{
// Structural tuple-element inference follows ProcessTypeForGenerics: later
// occurrences win. Interface/base candidates deliberately use strict bindings.
if (overwriteBindings)
{
bindings[parameter] = row;
return true;
}
if (bindings.TryGetValue(parameter, out var bound))
return SymbolEqualityComparer.Default.Equals(bound, row);
Comment thread
thomhurst marked this conversation as resolved.
Resolved
bindings.Add(parameter, row);
return true;
}
// A sole tuple parameter is reconstructed element by element at invocation.
// Closed elements do not contribute to inference: their values may support runtime
// conversions that their declared types cannot prove (for example object boxing int).
// Whole rows and interface/base candidates retain exact closed-argument matching.
// overwriteBindings marks structural element inference, not candidate validation.
if (!CodeGenerationHelpers.ContainsTypeParameter(parameter))
{
return (reconstructTupleElements && overwriteBindings) || SymbolEqualityComparer.Default.Equals(parameter, row);
Comment thread
thomhurst marked this conversation as resolved.
Resolved
}

// Tuple reconstruction can lift a non-nullable value into an open nullable element,
// just as ProcessTypeForGenerics and reflection TryInferTypeMapping do during inference.
if (reconstructTupleElements
&& parameter is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullableParameter
&& row.IsValueType
&& row is not INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T })
{
return MatchesOpenRowType(nullableParameter.TypeArguments[0], row, compilation, bindings, true, overwriteBindings);
}

// A single tuple argument is reconstructed by the generated binder, including across
// Tuple and ValueTuple families. Multi-parameter rows require an exact constructed shape,
// matching TupleHelper.MatchesOpenTuple, since their binder uses whole-type assignability.
if (reconstructTupleElements && GetTupleElementTypes(parameter) is { } parameterElements
&& GetTupleElementTypes(row) is { } rowElements && parameterElements.Length == rowElements.Length)
{
for (var i = 0; i < parameterElements.Length; i++)
{
// Sole tuple arguments are rebuilt with per-element conversions. Preserve
// the existing last-element inference through structurally matching elements;
// nested interface/base candidates still require consistent bindings.
var parameterElement = parameterElements[i];
var rowElement = rowElements[i];
if (parameterElement is INamedTypeSymbol
{ OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullableElement
&& rowElement.IsValueType)
{
parameterElement = nullableElement.TypeArguments[0];
if (rowElement is INamedTypeSymbol
{ OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullableRowElement)
rowElement = nullableRowElement.TypeArguments[0];
}
if (parameterElement is ITypeParameterSymbol)
{
if (!MatchesOpenRowType(parameterElement, rowElement, compilation, bindings, true, overwriteBindings)) return false;
continue;
}
if (!MatchesOpenRowType(parameterElements[i], rowElements[i], compilation, bindings, true, overwriteBindings)) return false;
}
return true;
}

if (parameter is IArrayTypeSymbol array
&& row is IArrayTypeSymbol rowArray && array.Rank == rowArray.Rank)
{
return MatchesOpenRowType(array.ElementType, rowArray.ElementType, compilation, bindings, reconstructTupleElements, overwriteBindings);
}

if (parameter is INamedTypeSymbol { IsGenericType: true } named
&& row is INamedTypeSymbol { IsGenericType: true } rowNamed
&& SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, rowNamed.OriginalDefinition))
{
var validateCandidate = reconstructTupleElements && !overwriteBindings;
for (var i = 0; i < named.TypeArguments.Length; i++)
{
// Infer the open arguments first. Closed arguments are checked on the completed
// candidate below, where interface variance has its correct direction and scope.
if (validateCandidate && !CodeGenerationHelpers.ContainsTypeParameter(named.TypeArguments[i])) continue;
if (!MatchesOpenRowType(named.TypeArguments[i], rowNamed.TypeArguments[i], compilation, bindings, reconstructTupleElements, overwriteBindings)) return false;
}
if (validateCandidate)
{
var typeParameters = bindings.Keys.Cast<ITypeParameterSymbol>().ToImmutableArray();
var typeArguments = typeParameters.Select(typeParameter => bindings[typeParameter]).ToArray();
var completedParameter = SubstituteTypeParameters(parameter, typeParameters, typeArguments, compilation);
return SatisfiesTypeConstraint(row, completedParameter, compilation);
}
return true;
}

// The binder also supports converting a derived element to a generic base class.
// Only reconstruction permits this conversion; whole multi-parameter rows stay invariant.
if (reconstructTupleElements && parameter is INamedTypeSymbol { IsGenericType: true, TypeKind: TypeKind.Class } classParameter)
{
for (var baseType = row.BaseType; baseType is not null; baseType = baseType.BaseType)
{
if (!SymbolEqualityComparer.Default.Equals(classParameter.OriginalDefinition, baseType.OriginalDefinition)) continue;
var candidateBindings = new Dictionary<ITypeSymbol, ITypeSymbol>(bindings, SymbolEqualityComparer.Default);
if (!MatchesOpenRowType(parameter, baseType, compilation, candidateBindings, true)) continue;
foreach (var binding in candidateBindings) bindings[binding.Key] = binding.Value;
return true;
}
}

// Reconstructed tuple elements may bind a concrete collection to an open interface.
// Keep candidate bindings isolated so a rejected interface cannot affect another match.
if (reconstructTupleElements && parameter is INamedTypeSymbol { IsGenericType: true, TypeKind: TypeKind.Interface } interfaceParameter)
{
foreach (var implementedInterface in row.AllInterfaces)
{
if (!SymbolEqualityComparer.Default.Equals(interfaceParameter.OriginalDefinition, implementedInterface.OriginalDefinition)) continue;
var candidateBindings = new Dictionary<ITypeSymbol, ITypeSymbol>(bindings, SymbolEqualityComparer.Default);
if (!MatchesOpenRowType(parameter, implementedInterface, compilation, candidateBindings, true)) continue;
foreach (var binding in candidateBindings) bindings[binding.Key] = binding.Value;
return true;
}
}

// An unmatched reconstructed element supplies no structural bindings. Its declared
// interface/base type may hide a compatible runtime value; let the binder cast it.
// Candidate validation remains strict because it never overwrites bindings.
return reconstructTupleElements && overwriteBindings;
Comment thread
thomhurst marked this conversation as resolved.
Resolved
}

private static ITypeSymbol[]? InferTypesFromMethodDataSourceRowType(TestMethodMetadata testMethod, AttributeData mdsAttr, ITypeSymbol[]? classTypes = null)
{
if (mdsAttr.ConstructorArguments.Length == 0)
Expand Down Expand Up @@ -7346,14 +7477,38 @@
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))
// A typed tuple parameter receives the whole row when the remaining parameters can be
// omitted (optional or a trailing collection). A bare type parameter normally
// receives the first element, as reflection mode unwraps tuple rows before inference.
// With explicit method types, preserve whole reference-tuple inference so it deduplicates
// against GenerateGenericTest<Tuple<...>> instead of adding a first-element instantiation.
Dictionary<ITypeSymbol, ITypeSymbol>? wholeTupleBindings = null;
var receivesWholeTuple = testMethodParams.Length > 0
&& ((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, compilation,
wholeTupleBindings = new Dictionary<ITypeSymbol, ITypeSymbol>(SymbolEqualityComparer.Default),
Comment thread
thomhurst marked this conversation as resolved.
Resolved
reconstructTupleElements: testMethodParams.Length == 1, overwriteBindings: testMethodParams.Length == 1))
Comment thread
thomhurst marked this conversation as resolved.
Resolved
|| (testMethodParams.Length == 1
&& testMethodParams[0].Type is ITypeParameterSymbol
&& rowType is INamedTypeSymbol { IsTupleType: false }
&& testMethod.MethodAttributes.Any(static attribute => attribute.AttributeClass?.IsOrInherits("global::TUnit.Core.GenerateGenericTestAttribute") is true)));

if (GetTupleElementTypes(rowType) is { } tupleElements && !receivesWholeTuple)
{
// 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 (receivesWholeTuple && wholeTupleBindings is { Count: > 0 })
{
foreach (var binding in wholeTupleBindings)
{
if (binding.Key is ITypeParameterSymbol parameter && genericParams.Contains(parameter))
genericParamMap[parameter.Name] = binding.Value;
}
}
else if (testMethodParams.Length > 0)
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
11 changes: 8 additions & 3 deletions src/TUnit.Core/Helpers/TestDataRowUnwrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ public static bool IsTestDataRowType(Type? type)
/// Unwraps an array of values, extracting TestDataRow metadata from single-element arrays.
/// </summary>
/// <param name="values">The array of values to unwrap.</param>
/// <param name="expectedTupleType">Preserve tuple payloads assignable to this parameter type.</param>
/// <param name="expectedTupleType">Preserve tuple payloads assignable to, or matching, this open tuple parameter type.</param>
/// <param name="reconstructTuple">Preserve any tuple payload for a sole tuple parameter, whose elements are reconstructed at invocation.</param>
/// <returns>A tuple of the unwrapped data array and any extracted metadata.</returns>
public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapArray(object?[] values, Type? expectedTupleType = null)
public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapArray(object?[] values, Type? expectedTupleType = null, bool reconstructTuple = false)
{
if (values.Length == 1 && TryUnwrap(values[0], out var data, out var metadata))
{
Expand All @@ -83,7 +84,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
&& (reconstructTuple || expectedTupleType.IsInstanceOfType(data)
|| expectedTupleType.ContainsGenericParameters
&& TupleHelper.MatchesOpenTuple(expectedTupleType, data!.GetType()))))
Comment thread
thomhurst marked this conversation as resolved.
Resolved
{
return (data.ToObjectArray(), metadata);
}
Expand Down
Loading
Loading