Skip to content

Commit c5e1fed

Browse files
authored
feat(mocks): span return .Returns() support, out span params, and in param tests (#5007)
* feat(mocks): add span return .Returns() support, out span parameters, and in parameter tests Add support for configuring return values on methods returning ReadOnlySpan<T>/Span<T> via .Returns() on the generated typed wrapper class. Span return values are stored as arrays via OutRefContext at index -1 and reconstructed at invocation time. Also adds comprehensive test coverage for: - Out/ref parameters of span types (ReadOnlySpan<T>, Span<T>) via array conversion - Span-returning methods (.Returns(), Callback, Throws, verification) - 'in' (readonly ref) parameters (argument matching, returns, callbacks, verification) * refactor: named constant for span return index, extract shared param readback - Add OutRefContext.SpanReturnValueIndex constant to replace magic -1 literal - Extract EmitOutRefParamAssignments shared helper to deduplicate the out/ref param reading loop between EmitOutRefReadback and EmitSpanReturnReadback
1 parent 9645314 commit c5e1fed

10 files changed

Lines changed: 2071 additions & 21 deletions

File tree

TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,15 @@ private static void GenerateWrapMethodBody(CodeWriter writer, MockMemberModel me
243243
writer.AppendLine($"if (_engine.TryHandleCall({method.MemberId}, \"{method.Name}\", {argsArray}))");
244244
writer.AppendLine("{");
245245
writer.IncreaseIndent();
246-
EmitOutRefReadback(writer, method);
247-
writer.AppendLine("return default;");
246+
if (method.SpanReturnElementType is not null)
247+
{
248+
EmitSpanReturnReadback(writer, method);
249+
}
250+
else
251+
{
252+
EmitOutRefReadback(writer, method);
253+
writer.AppendLine("return default;");
254+
}
248255
writer.DecreaseIndent();
249256
writer.AppendLine("}");
250257
writer.AppendLine($"return _wrappedInstance.{method.Name}({argPassList});");
@@ -520,8 +527,15 @@ private static void GeneratePartialMethodBody(CodeWriter writer, MockMemberModel
520527
writer.AppendLine($"if (_engine.TryHandleCall({method.MemberId}, \"{method.Name}\", {argsArray}))");
521528
writer.AppendLine("{");
522529
writer.IncreaseIndent();
523-
EmitOutRefReadback(writer, method);
524-
writer.AppendLine("return default;");
530+
if (method.SpanReturnElementType is not null)
531+
{
532+
EmitSpanReturnReadback(writer, method);
533+
}
534+
else
535+
{
536+
EmitOutRefReadback(writer, method);
537+
writer.AppendLine("return default;");
538+
}
525539
writer.DecreaseIndent();
526540
writer.AppendLine("}");
527541
writer.AppendLine($"return base.{method.Name}({argPassList});");
@@ -621,10 +635,18 @@ private static void GenerateEngineDispatchBody(CodeWriter writer, MockMemberMode
621635
{
622636
// Synchronous method returning a ref struct — can't use HandleCallWithReturn<T> because
623637
// ref structs can't be generic type arguments. Use void dispatch for call tracking,
624-
// callbacks, and throws. Return default (e.g. ReadOnlySpan<byte>.Empty).
638+
// callbacks, and throws.
625639
writer.AppendLine($"_engine.HandleCall({method.MemberId}, \"{method.Name}\", {argsArray});");
626-
EmitOutRefReadback(writer, method);
627-
writer.AppendLine("return default;");
640+
if (method.SpanReturnElementType is not null)
641+
{
642+
// Span return: read back out/ref params AND extract return value from OutRefContext index -1
643+
EmitSpanReturnReadback(writer, method);
644+
}
645+
else
646+
{
647+
EmitOutRefReadback(writer, method);
648+
writer.AppendLine("return default;");
649+
}
628650
}
629651
else
630652
{
@@ -943,11 +965,44 @@ private static void EmitOutRefReadback(CodeWriter writer, MockMemberModel method
943965
writer.AppendLine("var __outRef = global::TUnit.Mocks.Setup.OutRefContext.Consume();");
944966
using (writer.Block("if (__outRef is not null)"))
945967
{
946-
for (int i = 0; i < method.Parameters.Length; i++)
968+
EmitOutRefParamAssignments(writer, method);
969+
}
970+
}
971+
972+
/// <summary>
973+
/// For ref struct return methods with span support: emits code to consume OutRefContext,
974+
/// read back out/ref params, extract span return value, and return.
975+
/// Always ends with "return default;" as fallback.
976+
/// </summary>
977+
private static void EmitSpanReturnReadback(CodeWriter writer, MockMemberModel method)
978+
{
979+
writer.AppendLine("var __outRef = global::TUnit.Mocks.Setup.OutRefContext.Consume();");
980+
using (writer.Block("if (__outRef is not null)"))
981+
{
982+
EmitOutRefParamAssignments(writer, method);
983+
writer.AppendLine($"if (__outRef.TryGetValue(global::TUnit.Mocks.Setup.OutRefContext.SpanReturnValueIndex, out var __spanRet)) return new {method.ReturnType}(({method.SpanReturnElementType}[])__spanRet!);");
984+
}
985+
writer.AppendLine("return default;");
986+
}
987+
988+
/// <summary>
989+
/// Emits individual out/ref parameter assignments from the __outRef dictionary.
990+
/// Shared by <see cref="EmitOutRefReadback"/> and <see cref="EmitSpanReturnReadback"/>.
991+
/// </summary>
992+
private static void EmitOutRefParamAssignments(CodeWriter writer, MockMemberModel method)
993+
{
994+
for (int i = 0; i < method.Parameters.Length; i++)
995+
{
996+
var p = method.Parameters[i];
997+
if (p.IsRefStruct && p.SpanElementType is null) continue; // non-span ref structs can't be cast from object
998+
if (p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref)
947999
{
948-
var p = method.Parameters[i];
949-
if (p.IsRefStruct) continue; // ref structs can't be cast from object
950-
if (p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref)
1000+
if (p.SpanElementType is not null)
1001+
{
1002+
// Span types: reconstruct from stored array
1003+
writer.AppendLine($"if (__outRef.TryGetValue({i}, out var __v{i})) {p.Name} = new {p.FullyQualifiedType}(({p.SpanElementType}[])__v{i}!);");
1004+
}
1005+
else
9511006
{
9521007
writer.AppendLine($"if (__outRef.TryGetValue({i}, out var __v{i})) {p.Name} = ({p.FullyQualifiedType})__v{i}!;");
9531008
}

TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,13 @@ private static bool ShouldGenerateTypedWrapper(MockMemberModel method, bool hasE
8888
var matchableParams = method.Parameters.Where(p => p.Direction != ParameterDirection.Out && !p.IsRefStruct).ToList();
8989
if (matchableParams.Count == 0)
9090
{
91+
// Include span-type ref struct out/ref params (supported via array conversion)
9192
var hasOutRefParams = method.Parameters.Any(p =>
92-
!p.IsRefStruct && (p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref));
93-
return hasEvents || hasOutRefParams;
93+
(!p.IsRefStruct || p.SpanElementType is not null) &&
94+
(p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref));
95+
// Span return types need a typed wrapper for the generated Returns(SpanType) method
96+
var hasSpanReturn = method.SpanReturnElementType is not null;
97+
return hasEvents || hasOutRefParams || hasSpanReturn;
9498
}
9599
return matchableParams.Count <= MaxTypedParams;
96100
}
@@ -116,7 +120,7 @@ private static void GenerateUnifiedSealedClass(CodeWriter writer, MockMemberMode
116120
// Ref struct returns use the void wrapper (can't use generic type args with ref structs)
117121
if (method.IsVoid || method.IsRefStructReturn)
118122
{
119-
GenerateVoidUnifiedClass(writer, wrapperName, matchableParams, events, method.Parameters, hasRefStructParams, allNonOutParams);
123+
GenerateVoidUnifiedClass(writer, wrapperName, matchableParams, events, method.Parameters, hasRefStructParams, allNonOutParams, method.SpanReturnElementType, method.ReturnType);
120124
}
121125
else
122126
{
@@ -260,7 +264,8 @@ private static void GenerateReturnUnifiedClass(CodeWriter writer, string wrapper
260264

261265
private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperName,
262266
List<MockParameterModel> nonOutParams, EquatableArray<MockEventModel> events,
263-
EquatableArray<MockParameterModel> allParameters, bool hasRefStructParams, List<MockParameterModel> allNonOutParams)
267+
EquatableArray<MockParameterModel> allParameters, bool hasRefStructParams, List<MockParameterModel> allNonOutParams,
268+
string? spanReturnElementType = null, string? spanReturnType = null)
264269
{
265270
var builderType = "global::TUnit.Mocks.Setup.VoidMethodSetupBuilder";
266271
var hasOutRef = allParameters.Any(p => p.Direction == ParameterDirection.Out || p.Direction == ParameterDirection.Ref);
@@ -324,6 +329,14 @@ private static void GenerateVoidUnifiedClass(CodeWriter writer, string wrapperNa
324329
writer.AppendLine($"/// <inheritdoc />");
325330
writer.AppendLine($"public {wrapperName} Then() {{ EnsureSetup().Then(); return this; }}");
326331

332+
// Span return support: generate Returns(SpanType) that stores via SetsOutParameter(-1, ...)
333+
if (spanReturnElementType is not null && spanReturnType is not null)
334+
{
335+
writer.AppendLine();
336+
writer.AppendLine($"/// <summary>Configure the return value for this span-returning method.</summary>");
337+
writer.AppendLine($"public {wrapperName} Returns({spanReturnType} value) {{ EnsureSetup().SetsOutParameter(global::TUnit.Mocks.Setup.OutRefContext.SpanReturnValueIndex, value.ToArray()); return this; }}");
338+
}
339+
327340
// Typed parameter overloads (only for methods with typed params)
328341
if (nonOutParams.Count >= 1)
329342
{
@@ -466,12 +479,24 @@ private static void GenerateTypedOutRefMethods(CodeWriter writer, EquatableArray
466479
if (param.Direction != ParameterDirection.Out && param.Direction != ParameterDirection.Ref)
467480
continue;
468481

482+
// Skip non-span ref structs (can't be boxed)
483+
if (param.IsRefStruct && param.SpanElementType is null)
484+
continue;
485+
469486
var prefix = param.Direction == ParameterDirection.Out ? "SetsOut" : "SetsRef";
470487
var methodName = prefix + ToPascalCase(param.Name);
471488
var dirLabel = param.Direction == ParameterDirection.Out ? "out" : "ref";
472489

473490
writer.AppendLine($"/// <summary>Sets the '{param.Name}' {dirLabel} parameter to the specified value when this setup matches.</summary>");
474-
writer.AppendLine($"public {wrapperName} {methodName}({param.FullyQualifiedType} {param.Name}) {{ EnsureSetup().SetsOutParameter({i}, {param.Name}); return this; }}");
491+
if (param.SpanElementType is not null)
492+
{
493+
// Span types: convert to array for storage, reconstruct at invocation time
494+
writer.AppendLine($"public {wrapperName} {methodName}({param.FullyQualifiedType} {param.Name}) {{ EnsureSetup().SetsOutParameter({i}, {param.Name}.ToArray()); return this; }}");
495+
}
496+
else
497+
{
498+
writer.AppendLine($"public {wrapperName} {methodName}({param.FullyQualifiedType} {param.Name}) {{ EnsureSetup().SetsOutParameter({i}, {param.Name}); return this; }}");
499+
}
475500
}
476501
}
477502

TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,8 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m
301301
HasDefaultValue = p.HasExplicitDefaultValue,
302302
DefaultValueExpression = p.HasExplicitDefaultValue ? FormatDefaultValue(p) : null,
303303
IsValueType = p.Type.IsValueType,
304-
IsRefStruct = p.Type.IsRefLikeType
304+
IsRefStruct = p.Type.IsRefLikeType,
305+
SpanElementType = GetSpanElementType(p.Type)
305306
}).ToImmutableArray()
306307
),
307308
TypeParameters = new EquatableArray<MockTypeParameterModel>(
@@ -319,7 +320,8 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m
319320
IsVirtualMember = method.IsVirtual || method.IsOverride,
320321
IsProtected = method.DeclaredAccessibility == Accessibility.Protected
321322
|| method.DeclaredAccessibility == Accessibility.ProtectedOrInternal,
322-
IsRefStructReturn = returnType.IsRefLikeType
323+
IsRefStructReturn = returnType.IsRefLikeType,
324+
SpanReturnElementType = returnType.IsRefLikeType ? GetSpanElementType(returnType) : null
323325
};
324326
}
325327

@@ -369,7 +371,8 @@ private static MockMemberModel CreatePropertyModel(IPropertySymbol property, ref
369371
IsVirtualMember = property.IsVirtual || property.IsOverride,
370372
IsProtected = property.DeclaredAccessibility == Accessibility.Protected
371373
|| property.DeclaredAccessibility == Accessibility.ProtectedOrInternal,
372-
IsRefStructReturn = property.Type.IsRefLikeType
374+
IsRefStructReturn = property.Type.IsRefLikeType,
375+
SpanReturnElementType = property.Type.IsRefLikeType ? GetSpanElementType(property.Type) : null
373376
};
374377
}
375378

@@ -545,4 +548,25 @@ private static string GetMethodKey(IMethodSymbol method)
545548
if (value is char c) return $"'{c}'";
546549
return value.ToString();
547550
}
551+
552+
/// <summary>
553+
/// For ReadOnlySpan&lt;T&gt; or Span&lt;T&gt; types, returns the fully qualified element type.
554+
/// Returns null for all other types.
555+
/// </summary>
556+
private static string? GetSpanElementType(ITypeSymbol type)
557+
{
558+
if (type is not INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: 1 } namedType)
559+
return null;
560+
561+
var constructed = namedType.ConstructedFrom;
562+
var ns = constructed.ContainingNamespace?.ToDisplayString();
563+
var name = constructed.MetadataName;
564+
565+
if (ns == "System" && name is "ReadOnlySpan`1" or "Span`1")
566+
{
567+
return namedType.TypeArguments[0].GetFullyQualifiedName();
568+
}
569+
570+
return null;
571+
}
548572
}

TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ internal sealed record MockMemberModel : IEquatable<MockMemberModel>
3232
public bool IsProtected { get; init; }
3333
public bool IsRefStructReturn { get; init; }
3434

35+
/// <summary>
36+
/// For methods returning ReadOnlySpan&lt;T&gt; or Span&lt;T&gt;, the fully qualified element type.
37+
/// Null for non-span return types. Used to support configurable span return values via array conversion.
38+
/// </summary>
39+
public string? SpanReturnElementType { get; init; }
40+
3541
/// <summary>
3642
/// Returns true if the method has any non-out ref struct parameters.
3743
/// Computed from <see cref="Parameters"/> — does not participate in equality.
@@ -63,7 +69,8 @@ public bool Equals(MockMemberModel? other)
6369
&& IsAbstractMember == other.IsAbstractMember
6470
&& IsVirtualMember == other.IsVirtualMember
6571
&& IsProtected == other.IsProtected
66-
&& IsRefStructReturn == other.IsRefStructReturn;
72+
&& IsRefStructReturn == other.IsRefStructReturn
73+
&& SpanReturnElementType == other.SpanReturnElementType;
6774
}
6875

6976
public override int GetHashCode()

TUnit.Mocks.SourceGenerator/Models/MockParameterModel.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ internal sealed record MockParameterModel : IEquatable<MockParameterModel>
1313
public bool IsValueType { get; init; }
1414
public bool IsRefStruct { get; init; }
1515

16+
/// <summary>
17+
/// For ReadOnlySpan&lt;T&gt; or Span&lt;T&gt; parameters, the fully qualified element type (e.g. "byte").
18+
/// Null for non-span parameters. Used to support out/ref span parameters via array conversion.
19+
/// </summary>
20+
public string? SpanElementType { get; init; }
21+
1622
public bool Equals(MockParameterModel? other)
1723
{
1824
if (other is null) return false;
@@ -21,7 +27,8 @@ public bool Equals(MockParameterModel? other)
2127
&& FullyQualifiedType == other.FullyQualifiedType
2228
&& Direction == other.Direction
2329
&& IsValueType == other.IsValueType
24-
&& IsRefStruct == other.IsRefStruct;
30+
&& IsRefStruct == other.IsRefStruct
31+
&& SpanElementType == other.SpanElementType;
2532
}
2633

2734
public override int GetHashCode()
@@ -34,6 +41,7 @@ public override int GetHashCode()
3441
hash = hash * 31 + (int)Direction;
3542
hash = hash * 31 + IsValueType.GetHashCode();
3643
hash = hash * 31 + IsRefStruct.GetHashCode();
44+
hash = hash * 31 + (SpanElementType?.GetHashCode() ?? 0);
3745
return hash;
3846
}
3947
}

0 commit comments

Comments
 (0)