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
13 changes: 9 additions & 4 deletions Source/Mockolate.Analyzers/MockabilityAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ private static void ReportRefStructIssuesForType(SyntaxNodeAnalysisContext conte
if (type.TypeKind == TypeKind.Delegate)
{
if (type is INamedTypeSymbol { DelegateInvokeMethod: { } invoke, } &&
TryGetRefStructIssue(invoke, pipelineUnsupportedReason, out string? delegateIssue))
TryGetRefStructIssue(invoke, pipelineUnsupportedReason, out string? delegateIssue, isDelegate: true))
{
context.ReportDiagnostic(Diagnostic.Create(
s_refStructRule,
Expand Down Expand Up @@ -292,7 +292,7 @@ private static bool NeedsRefStructPipeline(ITypeSymbol type)
}

private static bool TryGetRefStructIssue(IMethodSymbol method, string? pipelineUnsupportedReason,
out string? issue)
out string? issue, bool isDelegate = false)
{
bool hasRefStructParam = false;
foreach (IParameterSymbol p in method.Parameters)
Expand All @@ -304,9 +304,14 @@ private static bool TryGetRefStructIssue(IMethodSymbol method, string? pipelineU

hasRefStructParam = true;

if (p.RefKind is RefKind.Out or RefKind.Ref or RefKind.RefReadOnlyParameter)
// Delegates don't go through the ref-struct setup pipeline at all, so any ref-struct
// parameter (by-value or by-ref) is unsupported on delegate Invoke methods — the
// emitted VoidMethodSetup<T> / ReturnMethodSetup<T> lacks an 'allows ref struct'
// constraint. Interface/class methods route through the IOutRefStructParameter /
// IRefRefStructParameter pipeline.
if (isDelegate)
{
issue = "out/ref ref-struct parameters are not supported";
issue = "ref-struct parameters are not supported on delegate types";
return true;
}
}
Comment on lines 294 to 317
Expand Down
60 changes: 50 additions & 10 deletions Source/Mockolate.SourceGenerators/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,25 @@ public static bool NeedsRefStructPipeline(this Type type)
=> type.IsRefStruct
&& type.SpecialGenericType is not (SpecialGenericType.Span or SpecialGenericType.ReadOnlySpan);

/// <summary>
/// Returns true if the parameter must flow through the ref-struct setup pipeline. The
/// <see cref="Type" /> overload excludes <c>Span&lt;T&gt;</c> and <c>ReadOnlySpan&lt;T&gt;</c>
/// because <c>by-value</c>, <c>out</c>, and <c>ref</c> positions use the
/// <c>SpanWrapper&lt;T&gt;</c> / <c>ReadOnlySpanWrapper&lt;T&gt;</c> non-ref-struct carve-out.
/// <c>ref readonly</c> Span/ROS, however, has no dedicated wrapper-based emit branch, so it
/// falls back to the generic ref-struct pipeline.
/// </summary>
public static bool NeedsRefStructPipeline(this MethodParameter parameter)
=> parameter.Type.NeedsRefStructPipeline();
{
if (parameter.Type.NeedsRefStructPipeline())
{
return true;
}

return parameter.RefKind == RefKind.RefReadOnlyParameter
&& parameter.Type.IsRefStruct
&& parameter.Type.SpecialGenericType is (SpecialGenericType.Span or SpecialGenericType.ReadOnlySpan);
}

extension(ITypeSymbol typeSymbol)
{
Expand Down Expand Up @@ -454,24 +471,47 @@ public string ToTypeOrWrapper()

public string ToParameter()
{
return (parameter.RefKind, parameter.Type.SpecialGenericType) switch
bool needsRefStructPipeline = parameter.NeedsRefStructPipeline();
return (parameter.RefKind, parameter.Type.SpecialGenericType, needsRefStructPipeline) switch
{
(RefKind.Ref, _) => $"global::Mockolate.Parameters.IRefParameter<{GetMethodParameterType(parameter)}>",
(RefKind.Out, _) => $"global::Mockolate.Parameters.IOutParameter<{GetMethodParameterType(parameter)}>",
(RefKind.RefReadOnlyParameter, _) => $"global::Mockolate.Parameters.IRefParameter<{GetMethodParameterType(parameter)}>",
(_, SpecialGenericType.Span) => $"global::Mockolate.Parameters.ISpanParameter<{GetMethodParameterType(parameter)}>",
(_, SpecialGenericType.ReadOnlySpan) =>
(RefKind.Ref, _, true) =>
$"global::Mockolate.Parameters.IRefRefStructParameter<{GetMethodParameterType(parameter)}>",
(RefKind.Out, _, true) =>
$"global::Mockolate.Parameters.IOutRefStructParameter<{GetMethodParameterType(parameter)}>",
(RefKind.RefReadOnlyParameter, _, true) =>
$"global::Mockolate.Parameters.IRefRefStructParameter<{GetMethodParameterType(parameter)}>",
(RefKind.Out, SpecialGenericType.Span, _) =>
$"global::Mockolate.Parameters.IOutParameter<global::Mockolate.Setup.SpanWrapper<{parameter.Type.GenericTypeParameters!.Value.First().Fullname}>>",
(RefKind.Out, SpecialGenericType.ReadOnlySpan, _) =>
$"global::Mockolate.Parameters.IOutParameter<global::Mockolate.Setup.ReadOnlySpanWrapper<{parameter.Type.GenericTypeParameters!.Value.First().Fullname}>>",
(RefKind.Ref, SpecialGenericType.Span, _) =>
$"global::Mockolate.Parameters.IRefParameter<global::Mockolate.Setup.SpanWrapper<{parameter.Type.GenericTypeParameters!.Value.First().Fullname}>>",
(RefKind.Ref, SpecialGenericType.ReadOnlySpan, _) =>
$"global::Mockolate.Parameters.IRefParameter<global::Mockolate.Setup.ReadOnlySpanWrapper<{parameter.Type.GenericTypeParameters!.Value.First().Fullname}>>",
(RefKind.Ref, _, _) => $"global::Mockolate.Parameters.IRefParameter<{GetMethodParameterType(parameter)}>",
(RefKind.Out, _, _) => $"global::Mockolate.Parameters.IOutParameter<{GetMethodParameterType(parameter)}>",
(RefKind.RefReadOnlyParameter, _, _) =>
$"global::Mockolate.Parameters.IRefParameter<{GetMethodParameterType(parameter)}>",
(_, SpecialGenericType.Span, _) =>
$"global::Mockolate.Parameters.ISpanParameter<{GetMethodParameterType(parameter)}>",
(_, SpecialGenericType.ReadOnlySpan, _) =>
$"global::Mockolate.Parameters.IReadOnlySpanParameter<{GetMethodParameterType(parameter)}>",
(_, _) => $"global::Mockolate.Parameters.IParameter<{GetMethodParameterType(parameter)}>",
(_, _, _) => $"global::Mockolate.Parameters.IParameter<{GetMethodParameterType(parameter)}>",
};

static string GetMethodParameterType(MethodParameter parameter)
{
// The non-ref-struct routes for Span/ROS use the SpanWrapper / ReadOnlySpanWrapper
// matcher, so the relevant generic argument is the element type. The ref-struct
// pipeline, by contrast, handles the bare Span<T> / ReadOnlySpan<T> directly, so we
// must preserve the full type there.
bool stripSpanToElement = !parameter.NeedsRefStructPipeline();
return (parameter.Type.SpecialGenericType,
parameter.IsNullableAnnotated && !parameter.Type.Fullname.EndsWith("?")) switch
{
(SpecialGenericType.Span, _) => parameter.Type.GenericTypeParameters!.Value.First().Fullname,
(SpecialGenericType.ReadOnlySpan, _) =>
(SpecialGenericType.Span, _) when stripSpanToElement =>
parameter.Type.GenericTypeParameters!.Value.First().Fullname,
(SpecialGenericType.ReadOnlySpan, _) when stripSpanToElement =>
parameter.Type.GenericTypeParameters!.Value.First().Fullname,
(_, false) => parameter.Type.Fullname,
(_, true) => $"{parameter.Type.Fullname}?",
Expand Down
132 changes: 99 additions & 33 deletions Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2267,8 +2267,9 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb,
sb.Append("\t\t\tvar ").Append(paramRef).Append(" = ").Append(p.Name).Append(';').AppendLine();
sb2.Append(paramRef);
}
else if (p.Type.SpecialGenericType == SpecialGenericType.Span ||
p.Type.SpecialGenericType == SpecialGenericType.ReadOnlySpan)
else if (p.RefKind != RefKind.Out &&
(p.Type.SpecialGenericType == SpecialGenericType.Span ||
p.Type.SpecialGenericType == SpecialGenericType.ReadOnlySpan))
{
string paramRef = Helpers.GetUniqueLocalVariableName($"ref_{p.Name}", method.Parameters);

Expand Down Expand Up @@ -2440,6 +2441,7 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb,
{
string outParamBase = Helpers.GetUniqueIndexedLocalVariableBase("outParam", method.Parameters);
string refParamBase = Helpers.GetUniqueIndexedLocalVariableBase("refParam", method.Parameters);
string outTempBase = Helpers.GetUniqueIndexedLocalVariableBase("outTemp", method.Parameters);
sb.Append("\t\t\t\tif (!").Append(hasWrappedResult).Append(" || ").Append(methodSetup).Append(" is ")
.Append(methodSetupType)
.Append(".WithParameterCollection)")
Expand All @@ -2454,17 +2456,45 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb,
parameterIndex++;
if (parameter.RefKind == RefKind.Out)
{
sb.Append("\t\t\t\t\t\tif (").Append(wpc).Append(".Parameter").Append(parameterIndex)
.Append(" is not global::Mockolate.Parameters.IOutParameter<")
.Append(parameter.Type.ToTypeOrWrapper()).Append("> ").Append(outParamBase)
.Append(parameterIndex)
.Append(" || !").Append(outParamBase).Append(parameterIndex).Append(".TryGetValue(out ")
.Append(parameter.Name).Append("))").AppendLine();
sb.Append("\t\t\t\t\t\t{").AppendLine();
sb.Append("\t\t\t\t\t\t\t").Append(parameter.Name).Append(" = ")
.AppendDefaultValueGeneratorFor(parameter.Type, $"{mockRegistry}.Behavior.DefaultValue")
.Append(';').AppendLine();
sb.Append("\t\t\t\t\t\t}").AppendLine();
bool isSpanOrReadOnlySpan = parameter.Type.SpecialGenericType
is SpecialGenericType.Span or SpecialGenericType.ReadOnlySpan;
if (isSpanOrReadOnlySpan)
{
// C# does not insert user-defined implicit conversions across `out`, so the
// wrapper-typed slot must be unpacked into a wrapper-typed temp local first,
// then assigned to the bare-typed parameter (which triggers the implicit op).
sb.Append("\t\t\t\t\t\tif (").Append(wpc).Append(".Parameter").Append(parameterIndex)
.Append(" is not global::Mockolate.Parameters.IOutParameter<")
.Append(parameter.Type.ToTypeOrWrapper()).Append("> ").Append(outParamBase)
.Append(parameterIndex)
.Append(" || !").Append(outParamBase).Append(parameterIndex)
.Append(".TryGetValue(out ").Append(parameter.Type.ToTypeOrWrapper())
.Append(' ').Append(outTempBase).Append(parameterIndex).Append("))").AppendLine();
sb.Append("\t\t\t\t\t\t{").AppendLine();
sb.Append("\t\t\t\t\t\t\t").Append(parameter.Name).Append(" = ")
.AppendDefaultValueGeneratorFor(parameter.Type, $"{mockRegistry}.Behavior.DefaultValue")
.Append(';').AppendLine();
sb.Append("\t\t\t\t\t\t}").AppendLine();
sb.Append("\t\t\t\t\t\telse").AppendLine();
sb.Append("\t\t\t\t\t\t{").AppendLine();
sb.Append("\t\t\t\t\t\t\t").Append(parameter.Name).Append(" = ").Append(outTempBase)
.Append(parameterIndex).Append(';').AppendLine();
sb.Append("\t\t\t\t\t\t}").AppendLine();
}
else
{
sb.Append("\t\t\t\t\t\tif (").Append(wpc).Append(".Parameter").Append(parameterIndex)
.Append(" is not global::Mockolate.Parameters.IOutParameter<")
.Append(parameter.Type.ToTypeOrWrapper()).Append("> ").Append(outParamBase)
.Append(parameterIndex)
.Append(" || !").Append(outParamBase).Append(parameterIndex).Append(".TryGetValue(out ")
.Append(parameter.Name).Append("))").AppendLine();
sb.Append("\t\t\t\t\t\t{").AppendLine();
sb.Append("\t\t\t\t\t\t\t").Append(parameter.Name).Append(" = ")
.AppendDefaultValueGeneratorFor(parameter.Type, $"{mockRegistry}.Behavior.DefaultValue")
.Append(';').AppendLine();
sb.Append("\t\t\t\t\t\t}").AppendLine();
}
}
else if (parameter.RefKind == RefKind.Ref)
{
Expand Down Expand Up @@ -2593,21 +2623,15 @@ private static void AppendMockSubject_ImplementClass_AddRefStructMethodBody(
{
sb.Append("#if NET9_0_OR_GREATER").AppendLine();

bool hasUnsupportedParameter =
method.Parameters.Any(p =>
(p.RefKind == RefKind.Out || p.RefKind == RefKind.Ref ||
p.RefKind == RefKind.RefReadOnlyParameter) && p.NeedsRefStructPipeline());
bool returnsUnsupportedRefStruct = method.ReturnType.IsRefStruct &&
method.ReturnType.SpecialGenericType is not
(SpecialGenericType.Span or SpecialGenericType.ReadOnlySpan);

if (hasUnsupportedParameter || returnsUnsupportedRefStruct)
if (returnsUnsupportedRefStruct)
{
string reason = returnsUnsupportedRefStruct
? "methods returning a non-span ref struct are not supported"
: "out/ref ref-struct parameters are not supported";
sb.Append("\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: ")
.Append(reason).Append(". Method '").Append(method.ContainingType).Append('.')
.Append("methods returning a non-span ref struct are not supported")
.Append(". Method '").Append(method.ContainingType).Append('.')
.Append(method.Name).Append("'.\");").AppendLine();
sb.Append("#else").AppendLine();
sb.Append(
Expand All @@ -2634,6 +2658,15 @@ method.ReturnType.SpecialGenericType is not

sb.Append("));").AppendLine();

// Pre-default any `out` ref-struct slot. The MockBehavior.DefaultValue generator returns
// object? and cannot produce ref-struct values, so we assign default! directly. If a
// matching setup later supplies a value via IOutRefStructParameter<T>.TryGetValue, that
// value overwrites this; otherwise the default! sticks.
foreach (MethodParameter outParameter in method.Parameters.Where(p => p.RefKind == RefKind.Out))
{
sb.Append("\t\t\t").Append(outParameter.Name).Append(" = default!;").AppendLine();
}

// Iterate setups in latest-registered-first order (scenario-scoped first, default-scope
// after — GetMethodSetups<T> preserves that ordering). Stop on the first matcher that
// accepts every positional argument. The matching runs synchronously on the stack so
Expand All @@ -2654,6 +2687,37 @@ method.ReturnType.SpecialGenericType is not
sb.AppendLine();
sb.Append("\t\t\t\t").Append(matchedVar).Append(" = true;").AppendLine();

// Per-slot write-back for out/ref ref-struct parameters. ref-readonly is read-only by
// definition and needs no write-back.
int refStructSlotIndex = 0;
foreach (MethodParameter parameter in method.Parameters)
{
refStructSlotIndex++;
if (parameter.RefKind == RefKind.Out)
{
string outVar = Helpers.GetUniqueLocalVariableName($"outParam{refStructSlotIndex}", method.Parameters);
sb.Append("\t\t\t\tif (").Append(setupVar).Append(".GetMatcher").Append(refStructSlotIndex)
.Append("() is global::Mockolate.Parameters.IOutRefStructParameter<")
.Append(parameter.Type.Fullname).Append("> ").Append(outVar).Append(" && ").Append(outVar)
.Append(".TryGetValue(out ").Append(parameter.Name).Append(")) { }").AppendLine();
sb.Append("\t\t\t\telse").AppendLine();
sb.Append("\t\t\t\t{").AppendLine();
sb.Append("\t\t\t\t\t").Append(parameter.Name).Append(" = default!;").AppendLine();
sb.Append("\t\t\t\t}").AppendLine();
}
else if (parameter.RefKind == RefKind.Ref)
{
string refVar = Helpers.GetUniqueLocalVariableName($"refParam{refStructSlotIndex}", method.Parameters);
sb.Append("\t\t\t\tif (").Append(setupVar).Append(".GetMatcher").Append(refStructSlotIndex)
.Append("() is global::Mockolate.Parameters.IRefRefStructParameter<")
.Append(parameter.Type.Fullname).Append("> ").Append(refVar).Append(")").AppendLine();
sb.Append("\t\t\t\t{").AppendLine();
sb.Append("\t\t\t\t\t").Append(parameter.Name).Append(" = ").Append(refVar).Append(".GetValue(")
.Append(parameter.Name).Append(");").AppendLine();
sb.Append("\t\t\t\t}").AppendLine();
}
}

if (method.ReturnType == Type.Void)
{
sb.Append("\t\t\t\t").Append(setupVar).Append(".Invoke(").Append(paramNames).Append(");").AppendLine();
Expand Down Expand Up @@ -3774,16 +3838,19 @@ private static void AppendMethodSetupImplementation(StringBuilder sb, Method met
private static void AppendRefStructMethodSetupDefinition(StringBuilder sb, Method method,
string? methodNameOverride)
{
// Out/ref/ref-readonly slots that don't go through the ref-struct setup pipeline have no setup
// surface today. That includes regular non-ref-struct types and out/ref Span/ReadOnlySpan
// (which route through the SpanWrapper/ReadOnlySpanWrapper carve-out, not IParameterMatch<T>).
// The ref-struct narrow setup uses IParameterMatch<T> slots; mixing in a wrapper-based or
// non-ref-struct IOutParameter<T> would require a wider pipeline. Continue to skip those.
bool unsupported = method.Parameters.Any(p =>
p.RefKind == RefKind.Out || p.RefKind == RefKind.Ref ||
p.RefKind == RefKind.RefReadOnlyParameter) ||
(p.RefKind == RefKind.Out || p.RefKind == RefKind.Ref ||
p.RefKind == RefKind.RefReadOnlyParameter)
&& !p.NeedsRefStructPipeline()) ||
(method.ReturnType.IsRefStruct && method.ReturnType.SpecialGenericType is not
(SpecialGenericType.Span or SpecialGenericType.ReadOnlySpan));
if (unsupported)
{
// No setup surface — the mock method body throws NotSupportedException at runtime and
// the analyzer flags the signature at build time. Skipping the declaration entirely
// keeps the setup interface clean.
return;
}

Expand All @@ -3806,8 +3873,7 @@ private static void AppendRefStructMethodSetupDefinition(StringBuilder sb, Metho
sb.Append(", ");
}

sb.Append("global::Mockolate.Parameters.IParameter<").Append(parameter.Type.Fullname)
.Append(">? ").Append(parameter.Name);
sb.Append(parameter.ToParameter()).Append("? ").Append(parameter.Name);
}

sb.Append(");").AppendLine();
Expand All @@ -3828,8 +3894,9 @@ private static void AppendRefStructMethodSetupImplementation(StringBuilder sb, M
#pragma warning restore S107
{
bool unsupported = method.Parameters.Any(p =>
p.RefKind == RefKind.Out || p.RefKind == RefKind.Ref ||
p.RefKind == RefKind.RefReadOnlyParameter) ||
(p.RefKind == RefKind.Out || p.RefKind == RefKind.Ref ||
p.RefKind == RefKind.RefReadOnlyParameter)
&& !p.NeedsRefStructPipeline()) ||
(method.ReturnType.IsRefStruct && method.ReturnType.SpecialGenericType is not
(SpecialGenericType.Span or SpecialGenericType.ReadOnlySpan));
if (unsupported)
Expand Down Expand Up @@ -3859,8 +3926,7 @@ private static void AppendRefStructMethodSetupImplementation(StringBuilder sb, M
sb.Append(", ");
}

sb.Append("global::Mockolate.Parameters.IParameter<").Append(parameter.Type.Fullname)
.Append(">? ").Append(parameter.Name);
sb.Append(parameter.ToParameter()).Append("? ").Append(parameter.Name);
}

sb.Append(")").AppendLine();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,19 @@ private static void AppendRefStructVoidMethodSetup(StringBuilder sb, int numberO
sb.Append(';').AppendLine();
sb.AppendLine();

// Per-slot matcher accessors. Used by generated mock bodies to access
// IOutRefStructParameter<T>/IRefRefStructParameter<T> payloads on out/ref slots.
for (int i = 1; i <= numberOfParameters; i++)
{
sb.Append(
"\t\t[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]")
.AppendLine();
sb.Append("\t\tpublic global::Mockolate.Parameters.IParameterMatch<T").Append(i)
.Append(">? GetMatcher").Append(i).Append("() => _matcher").Append(i).Append(';').AppendLine();
}

sb.AppendLine();

// Invoke.
sb.Append("\t\tpublic void Invoke(");
for (int i = 1; i <= numberOfParameters; i++)
Expand Down Expand Up @@ -386,6 +399,19 @@ private static void AppendRefStructReturnMethodSetup(StringBuilder sb, int numbe
sb.Append(';').AppendLine();
sb.AppendLine();

// Per-slot matcher accessors. Used by generated mock bodies to access
// IOutRefStructParameter<T>/IRefRefStructParameter<T> payloads on out/ref slots.
for (int i = 1; i <= numberOfParameters; i++)
{
sb.Append(
"\t\t[global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)]")
.AppendLine();
sb.Append("\t\tpublic global::Mockolate.Parameters.IParameterMatch<T").Append(i)
.Append(">? GetMatcher").Append(i).Append("() => _matcher").Append(i).Append(';').AppendLine();
}

sb.AppendLine();

// Invoke.
sb.Append("\t\tpublic TReturn Invoke(");
for (int i = 1; i <= numberOfParameters; i++)
Expand Down
Loading
Loading