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
26 changes: 26 additions & 0 deletions TUnit.Mocks.SourceGenerator.Tests/MockGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,32 @@ void M()
return VerifyGeneratorOutput(source);
}

[Test]
public Task Interface_With_Params_Array_Parameter()
{
var source = """
using TUnit.Mocks;

public interface IParamsSink
{
int Sum(params int[] values);
string Render(params object[] args);
string Combine(string prefix, params string[] parts);
T First<T>(params T[] items);
}

public class TestUsage
{
void M()
{
var mock = Mock.Of<IParamsSink>();
}
}
""";

return VerifyGeneratorOutput(source);
}

[Test]
public Task Multi_Method_Interface()
{
Expand Down

Large diffs are not rendered by default.

164 changes: 162 additions & 2 deletions TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -781,15 +781,18 @@ private static void GenerateMemberMethod(CodeWriter writer, MockMemberModel meth
writer.AppendLine("#if NET9_0_OR_GREATER");
EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: true, captureModelTypeParameters: false, receiverIsThis: false);
EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: true, captureModelTypeParameters: false, receiverIsThis: false);
EmitParamsExpandedOverload(writer, method, model, safeName, includeRefStructArgs: true, captureModelTypeParameters: false, receiverIsThis: false);
writer.AppendLine("#else");
EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: false, receiverIsThis: false);
EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: false, receiverIsThis: false);
EmitParamsExpandedOverload(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: false, receiverIsThis: false);
writer.AppendLine("#endif");
}
else
{
EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: false, receiverIsThis: false);
EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: false, receiverIsThis: false);
EmitParamsExpandedOverload(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: false, receiverIsThis: false);
}

EmitAnyArgsOverload(writer, method, model, safeName, captureModelTypeParameters: false, receiverIsThis: false);
Expand Down Expand Up @@ -823,15 +826,18 @@ private static void GenerateGenericMethodMembersInCurrentBlock(CodeWriter writer
writer.AppendLine("#if NET9_0_OR_GREATER");
EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: true, captureModelTypeParameters: true, receiverIsThis: false);
EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: true, captureModelTypeParameters: true, receiverIsThis: false);
EmitParamsExpandedOverload(writer, method, model, safeName, includeRefStructArgs: true, captureModelTypeParameters: true, receiverIsThis: false);
writer.AppendLine("#else");
EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: false);
EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: false);
EmitParamsExpandedOverload(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: false);
writer.AppendLine("#endif");
}
else
{
EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: false);
EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: false);
EmitParamsExpandedOverload(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: false);
}

EmitAnyArgsOverload(writer, method, model, safeName, captureModelTypeParameters: true, receiverIsThis: false);
Expand All @@ -844,15 +850,18 @@ internal static void GenerateGenericMethodMembersForWrapper(CodeWriter writer, M
writer.AppendLine("#if NET9_0_OR_GREATER");
EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: true, captureModelTypeParameters: true, receiverIsThis: true);
EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: true, captureModelTypeParameters: true, receiverIsThis: true);
EmitParamsExpandedOverload(writer, method, model, safeName, includeRefStructArgs: true, captureModelTypeParameters: true, receiverIsThis: true);
writer.AppendLine("#else");
EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: true);
EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: true);
EmitParamsExpandedOverload(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: true);
writer.AppendLine("#endif");
}
else
{
EmitMemberMethodBody(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: true);
EmitFuncOverloads(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: true);
EmitParamsExpandedOverload(writer, method, model, safeName, includeRefStructArgs: false, captureModelTypeParameters: true, receiverIsThis: true);
}

EmitAnyArgsOverload(writer, method, model, safeName, captureModelTypeParameters: true, receiverIsThis: true);
Expand Down Expand Up @@ -1458,11 +1467,23 @@ private static void GenerateRaiseExtensionMethods(CodeWriter writer, MockTypeMod
}
}

private static string GetArgParameterList(MockMemberModel method, bool includeRefStructArgs)
/// <param name="lastParamOverride">
/// When non-null, emits the method's last parameter with this renderer instead of the default
/// <c>Arg&lt;T&gt;</c> form — used for the params-expanded (<c>params Arg&lt;TElem&gt;[]</c>)
/// and <c>AnyArg</c>-slotted overloads of <c>params T[]</c> methods.
/// </param>
private static string GetArgParameterList(MockMemberModel method, bool includeRefStructArgs,
Func<MockParameterModel, string>? lastParamOverride = null)
{
var parts = new List<string>();
foreach (var p in method.Parameters)
for (var i = 0; i < method.Parameters.Length; i++)
{
var p = method.Parameters[i];
if (lastParamOverride is not null && i == method.Parameters.Length - 1)
{
parts.Add(lastParamOverride(p));
continue;
}
if (p.Direction == ParameterDirection.Out)
{
// Normally out params are omitted from the extension signature so callers
Expand Down Expand Up @@ -1491,6 +1512,145 @@ private static string GetArgParameterList(MockMemberModel method, bool includeRe
return string.Join(", ", parts);
}

/// <summary>
/// Emits a params-expanded setup overload for methods whose last parameter is <c>params T[]</c>:
/// the trailing <c>Arg&lt;T[]&gt;</c> slot becomes <c>params Arg&lt;T&gt;[]</c> so callers can match
/// per element (<c>mock.Sum(Is(1), Is(2))</c>). The element matchers are wrapped into a single
/// <c>ParamsArrayMatcher</c> so matcher arity stays equal to the declared parameter count.
/// The whole-array overload remains and wins normal-form overload resolution for
/// <c>Sum(Any())</c> / <c>Sum(array)</c>, preserving existing behavior.
/// </summary>
private static void EmitParamsExpandedOverload(CodeWriter writer, MockMemberModel method, MockTypeModel model,
string safeName, bool includeRefStructArgs, bool captureModelTypeParameters, bool receiverIsThis)
{
var last = method.Parameters.Length > 0 ? method.Parameters[method.Parameters.Length - 1] : null;
if (last is null || last.ParamsElementType is null || last.Direction != ParameterDirection.In)
{
return;
}

var (useTypedWrapper, returnType, setupReturnType) = GetReturnTypeInfo(method, model, safeName);

var paramList = GetArgParameterList(method, includeRefStructArgs,
p => $"params global::TUnit.Mocks.Arguments.Arg<{p.ParamsElementType}>[] {p.Name}");
var typeParams = captureModelTypeParameters
? MockImplBuilder.GetTypeParameterList(method)
: GetCombinedTypeParameterList(model, method);
var constraints = captureModelTypeParameters
? MockImplBuilder.GetConstraintClauses(method)
: GetCombinedConstraintClauses(model, method);

var safeMemberName = GetSafeMemberName(method.Name, model);
var fullParamList = captureModelTypeParameters
? paramList
: BuildExtensionMethodParameterList(model, paramList);

var methodDeclarationPrefix = captureModelTypeParameters ? "public" : "public static";

writer.AppendLine($"/// <summary>Configure the mock setup for <c>{method.Name}</c> with per-element matchers for its <c>params</c> parameter.</summary>");
using (writer.Block($"{methodDeclarationPrefix} {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}"))
{
if (receiverIsThis)
{
writer.AppendLine("var mock = this;");
}

EmitOutParamDefaults(writer, method);

writer.AppendLine($"var __paramsElementMatchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[{last.Name}.Length];");
using (writer.Block($"for (int __i = 0; __i < {last.Name}.Length; __i++)"))
{
writer.AppendLine($"__paramsElementMatchers[__i] = {last.Name}[__i].Matcher;");
}

EmitMatchersArrayWithParamsSlot(writer, method, includeRefStructArgs,
"new global::TUnit.Mocks.Matchers.ParamsArrayMatcher(__paramsElementMatchers)");

EmitReturnConstruction(writer, method, model, safeName, useTypedWrapper, setupReturnType);
}

EmitParamsAnyArgOverload(writer, method, model, safeName, last, includeRefStructArgs, captureModelTypeParameters, receiverIsThis);
}

/// <summary>
/// Emits an overload whose <c>params</c> slot is the untyped <c>AnyArg</c> sentinel, matching the
/// whole packed array with <c>AnyMatcher&lt;T[]&gt;</c>. Without it, <c>Sum(Any())</c> would be
/// ambiguous between the whole-array <c>Arg&lt;T[]&gt;</c> overload and the params-expanded
/// <c>params Arg&lt;T&gt;[]</c> overload (AnyArg converts to both, and the normal-vs-expanded
/// tie-break only applies to identical parameter lists). The identity conversion to <c>AnyArg</c>
/// beats both user-defined conversions, preserving the pre-existing whole-array semantics.
/// </summary>
private static void EmitParamsAnyArgOverload(CodeWriter writer, MockMemberModel method, MockTypeModel model,
string safeName, MockParameterModel last, bool includeRefStructArgs, bool captureModelTypeParameters, bool receiverIsThis)
{
static string AnyArgLastParam(MockParameterModel p) => $"global::TUnit.Mocks.Arguments.AnyArg {p.Name}";

var paramList = GetArgParameterList(method, includeRefStructArgs, AnyArgLastParam);

// Two same-name params methods that differ only in element type (e.g. M(params int[]) and
// M(params string[])) would both produce this AnyArg-slotted signature — skip on collision.
foreach (var m in model.Methods)
{
if (m.MemberId == method.MemberId || m.Name != method.Name) continue;
if (m.ExplicitInterfaceName is not null && !m.IsStaticAbstract) continue;
if (m.TypeParameters.Length != method.TypeParameters.Length) continue;
var mLast = m.Parameters.Length > 0 ? m.Parameters[m.Parameters.Length - 1] : null;
if (mLast is null || mLast.ParamsElementType is null || mLast.Direction != ParameterDirection.In) continue;
if (GetArgParameterList(m, includeRefStructArgs, AnyArgLastParam) == paramList) return;
}

var (useTypedWrapper, returnType, setupReturnType) = GetReturnTypeInfo(method, model, safeName);

var typeParams = captureModelTypeParameters
? MockImplBuilder.GetTypeParameterList(method)
: GetCombinedTypeParameterList(model, method);
var constraints = captureModelTypeParameters
? MockImplBuilder.GetConstraintClauses(method)
: GetCombinedConstraintClauses(model, method);

var safeMemberName = GetSafeMemberName(method.Name, model);
var fullParamList = captureModelTypeParameters
? paramList
: BuildExtensionMethodParameterList(model, paramList);

var methodDeclarationPrefix = captureModelTypeParameters ? "public" : "public static";

writer.AppendLine($"/// <summary>Configure the mock setup for <c>{method.Name}</c> with its <c>params</c> parameter matched as a whole array by <c>Any()</c>.</summary>");
using (writer.Block($"{methodDeclarationPrefix} {returnType} {safeMemberName}{typeParams}({fullParamList}){constraints}"))
{
if (receiverIsThis)
{
writer.AppendLine("var mock = this;");
}

EmitOutParamDefaults(writer, method);

// AnyMatcher<T[]>.Instance: whole-array Any, identical to the Arg<T[]> overload with Any().
EmitMatchersArrayWithParamsSlot(writer, method, includeRefStructArgs,
$"global::TUnit.Mocks.Matchers.AnyMatcher<{last.FullyQualifiedType}>.Instance");

EmitReturnConstruction(writer, method, model, safeName, useTypedWrapper, setupReturnType);
}
}

/// <summary>
/// Emits the <c>matchers</c> array for a params-method overload: leading matchable parameters
/// contribute <c>{name}.Matcher</c>, and the trailing params slot is filled with
/// <paramref name="paramsSlotExpression"/>. The params parameter is always <c>In</c> and never
/// a ref struct, so it is always the last matchable parameter.
/// </summary>
private static void EmitMatchersArrayWithParamsSlot(CodeWriter writer, MockMemberModel method,
bool includeRefStructArgs, string paramsSlotExpression)
{
var matchableParams = includeRefStructArgs
? method.Parameters.Where(p => p.Direction != ParameterDirection.Out).ToList()
: method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList();

var matcherArgs = matchableParams
.Select((p, i) => i == matchableParams.Count - 1 ? paramsSlotExpression : $"{p.Name}.Matcher");
writer.AppendLine($"var matchers = new global::TUnit.Mocks.Arguments.IArgumentMatcher[] {{ {string.Join(", ", matcherArgs)} }};");
}

private static string BuildExtensionMethodParameterList(MockTypeModel model, string paramList)
{
var mockableType = MockImplBuilder.GetMockableTypeName(model);
Expand Down
7 changes: 6 additions & 1 deletion TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,12 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m
DefaultValueExpression = p.HasExplicitDefaultValue ? FormatDefaultValue(p) : null,
IsValueType = p.Type.IsValueType,
IsRefStruct = p.Type.IsRefLikeType,
SpanElementType = GetSpanElementType(p.Type)
SpanElementType = GetSpanElementType(p.Type),
// Only single-dimensional arrays get the params-expanded setup overload;
// params collections (C# 13) and params spans degrade to whole-value matching.
ParamsElementType = p.IsParams && p.Type is IArrayTypeSymbol paramsArray
? paramsArray.ElementType.GetFullyQualifiedNameWithNullability()
: null
}).ToImmutableArray()
),
TypeParameters = new EquatableArray<MockTypeParameterModel>(
Expand Down
15 changes: 14 additions & 1 deletion TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ internal sealed record MockParameterModel : IEquatable<MockParameterModel>
/// </summary>
public bool IsNonSpanRefStruct => IsRefStruct && SpanElementType is null;

/// <summary>
/// For <c>params T[]</c> parameters, the fully qualified element type with nullability (e.g.
/// "global::System.Object"). Null for non-params parameters and for params collections that
/// are not single-dimensional arrays. Non-null implies the parameter is a params array — there
/// is no separate flag. Used to emit the params-expanded setup overload that accepts
/// per-element <c>Arg&lt;T&gt;</c> matchers. Only populated for methods: indexers cannot
/// declare <c>params</c> parameters that surface in setups, and delegate parameters are only
/// used for event raising.
/// </summary>
public string? ParamsElementType { get; init; }

public bool Equals(MockParameterModel? other)
{
if (other is null) return false;
Expand All @@ -35,7 +46,8 @@ public bool Equals(MockParameterModel? other)
&& Direction == other.Direction
&& IsValueType == other.IsValueType
&& IsRefStruct == other.IsRefStruct
&& SpanElementType == other.SpanElementType;
&& SpanElementType == other.SpanElementType
&& ParamsElementType == other.ParamsElementType;
}

public override int GetHashCode()
Expand All @@ -49,6 +61,7 @@ public override int GetHashCode()
hash = hash * 31 + IsValueType.GetHashCode();
hash = hash * 31 + IsRefStruct.GetHashCode();
hash = hash * 31 + (SpanElementType?.GetHashCode() ?? 0);
hash = hash * 31 + (ParamsElementType?.GetHashCode() ?? 0);
return hash;
}
}
Expand Down
Loading
Loading