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
50 changes: 41 additions & 9 deletions Docs/pages/08-analyzers.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ configuration or verification. The same narrowing applies to indexers. See

## Mockolate0003

A mocked member's signature routes through the ref-struct pipeline in a way Mockolate can't
emit setup surface for. The warning fires in two distinct situations.
A mocked member uses a ref struct in a way Mockolate can't emit setup surface for. The warning fires
in two distinct situations.

**1. Compilation prerequisites not met**

Expand All @@ -91,17 +91,49 @@ framework and/or `<LangVersion>` to resolve it.

**2. Signature shapes that are never supported**

These fire on every compilation target, including .NET 9+ / C# 13+:
These fire on every compilation target, including .NET 9+ / C# 13+, because the shape has no setup
surface to route into rather than because the pipeline is missing.

- Parameters marked `out`, `ref`, or `ref readonly` whose type is a non-`Span<T>` /
non-`ReadOnlySpan<T>` ref struct - the mock can't round-trip the value through
`IOutParameter<T>` / `IRefParameter<T>` when `T` is a ref struct.
- Methods returning a non-`Span<T>` / non-`ReadOnlySpan<T>` ref struct.
A non-`Span<T>` / non-`ReadOnlySpan<T>` ref struct in a **value position**:

- Methods and delegates returning one.
- Properties typed as one.
- Indexers whose value is one.

Value positions parameterize types that store a `Func<T>` (`IReturnMethodSetup<T>`,
`IPropertyGetterOnlySetup<T>`, `IIndexerGetterOnlySetup<TValue, ...>`), which is illegal for a ref
struct, so they can't carry the `allows ref struct` anti-constraint the parameter positions use.

Also never supported:

- Ref-struct **parameters on delegate types**. A delegate mock projects its single `Invoke` onto
`VoidMethodSetup<T>` / `ReturnMethodSetup<T>`, neither of which carries the anti-constraint the
interface and class pipelines get from `RefStructVoidMethodSetup<T>`. This includes
`ref readonly Span<T>` / `ref readonly ReadOnlySpan<T>`, the one span ref kind with no wrapper-based
emit branch.

Members matching any of these still compile: the generated mock keeps the member, but no setup or
verify surface is emitted for it. Everything else on the type stays mockable.

What the member does at runtime depends on whether there is an implementation behind it:

- A `virtual` member on a mocked class forwards to the wrapped instance, or to `base`, so it keeps
behaving like the real one. **These are not flagged** - there is nothing to fix. The forward
honours none of the [`MockBehavior`](create-mocks#customizing-mock-behavior) flags, because there
is no setup to honour them against: `SkipBaseClass` has no configured value to return in the base
call's place, and `ThrowWhenNotSetup` would reject a member that can never be set up. The call is
not recorded either.
- An interface member, an `abstract` member, an `init` accessor and a `ref`-returning method have
nothing to forward to and throw `NotSupportedException`. These are the ones the warning reports.

**Note:**
`Span<T>` and `ReadOnlySpan<T>` flow through the existing `SpanWrapper` / `ReadOnlySpanWrapper`
fallback and are never flagged. On .NET 9+ with C# 13+, by-value custom ref-struct parameters and
ref-struct-keyed indexers (getter-only, setter-only, and get+set) are fully supported.
fallback and are never flagged - in parameter *and* value positions. The one exception is
`ref readonly`, which has no wrapper-based emit branch and routes through the ref-struct pipeline
like a custom ref struct: on an interface or class it needs .NET 9 / C# 13, and on a delegate it is
unsupported. On .NET 9+ with C# 13+, custom ref-struct parameters (by value, `out`, `ref`, and
`ref readonly`) and ref-struct-keyed indexers (getter-only, setter-only, and get+set) on interfaces
and classes are fully supported.

See the [Ref Struct Parameters](setup/parameter-matching#ref-struct-parameters-net-9) section
for the supported surface.
23 changes: 17 additions & 6 deletions Docs/pages/setup/04-parameter-matching.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,12 +264,23 @@ generic delegates:
contribute their raw value as part of the composite dispatch key. If any ref-struct slot is
matched without a projection, storage stays inactive for that setup.

The following cases are rejected at compile time with diagnostic `Mockolate0003`:

- Targeting older than .NET 9 (the feature relies on `allows ref struct`, a .NET 9 / C# 13
feature).
- `out` / `ref` / `ref readonly` parameters of a ref-struct type.
- Methods that return a custom ref struct. (`Span<T>` / `ReadOnlySpan<T>` returns are supported.)
On .NET 9 with C# 13 or later, ref-struct parameters are supported by value and as `out`, `ref`
and `ref readonly`, on interface and class members alike.

The following cases are flagged with diagnostic `Mockolate0003`:

- Targeting older than .NET 9, or pinning `<LangVersion>` below 13 (the feature relies on
`allows ref struct`, a .NET 9 / C# 13 feature).
- A custom ref struct in a *value* position: a method or delegate return, a property type, or an
indexer value. (`Span<T>` / `ReadOnlySpan<T>` are supported in those positions.)
- Ref-struct parameters on delegate types, `ref readonly Span<T>` / `ref readonly ReadOnlySpan<T>`
included - that ref kind is the one span shape without a wrapper-based emit branch, so it routes
through the ref-struct pipeline that delegates do not have.

Members in the latter two groups still compile - the mock keeps the member but emits no setup or
verify surface for it. A `virtual` class member is not flagged at all, because the mock forwards it
to the wrapped instance or to `base` and it keeps working. See
[Mockolate0003](../analyzers#mockolate0003) for what the member does at runtime.

## Parameter Predicates

Expand Down
2 changes: 1 addition & 1 deletion Source/Mockolate.Analyzers/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
---------------|----------|----------|----------------------------------------------------------
Mockolate0001 | Usage | Error | Verifications must be used
Mockolate0002 | Usage | Error | Mock arguments must be mockable
Mockolate0003 | Usage | Warning | Ref-struct parameter mocking not supported on this build
Mockolate0003 | Usage | Warning | Ref-struct usage is not supported on this compilation
94 changes: 85 additions & 9 deletions Source/Mockolate.Analyzers/MockabilityAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,15 @@ when TryGetRefStructIssueForIndexer(p, pipelineUnsupportedReason, out string? is
"this[]",
issue));
break;
case IPropertySymbol { IsIndexer: false, } p
when NeedsRefStructPipeline(p.Type) && !ForwardsToBaseImplementation(p):
context.ReportDiagnostic(Diagnostic.Create(
s_refStructRule,
location,
type.ToDisplayString(),
p.Name,
"properties of a non-span ref struct type are not supported"));
break;
}
}
}
Expand Down Expand Up @@ -266,6 +275,47 @@ private static bool NeedsRefStructPipeline(ITypeSymbol type)
type.Name is "Span" or "ReadOnlySpan");
}

/// <summary>
/// Mirrors the <c>MethodParameter</c> overload of <c>Helpers.NeedsRefStructPipeline</c>: on top of
/// the type-level rule, a <c>ref readonly</c> <c>Span&lt;T&gt;</c>/<c>ReadOnlySpan&lt;T&gt;</c> has
/// no wrapper-based emit branch on the interface and class pipelines and falls back to the generic
/// ref-struct path, so it is as unsupported as a custom ref struct is. Deliberately a separate name
/// rather than an overload of <see cref="NeedsRefStructPipeline(ITypeSymbol)" />: the two rules do
/// not agree, and picking the wrong one silently is exactly the drift this guards against - every
/// parameter position mirrors this one, the type-level rule covers returns and property values.
/// </summary>
private static bool ParameterNeedsRefStructPipeline(IParameterSymbol parameter)
=> NeedsRefStructPipeline(parameter.Type) ||
(parameter.RefKind == RefKind.RefReadOnlyParameter && parameter.Type.IsRefLikeType);

/// <summary>
/// Whether the generator degrades <paramref name="member" /> to a passthrough rather than a
/// <c>NotSupportedException</c> stub, in which case the mock keeps behaving like the real member
/// and there is nothing for the user to act on. Mirrors <c>forwardsUnsupportedRefStructValue</c>
/// and <c>AppendUnsupportedRefStructReturnBody</c> in <c>Sources.MockClass</c>: only a member
/// with a real implementation behind it - a non-abstract member declared on a class - can
/// forward to the wrapped instance or to <c>base</c>. Static members need no handling here:
/// <see cref="IsOverriddenByGenerator" /> already drops them, since a class member the
/// generator overrides is necessarily <c>abstract</c> or <c>virtual</c>.
/// </summary>
private static bool ForwardsToBaseImplementation(ISymbol member)
{
if (member.ContainingType.TypeKind is TypeKind.Interface or TypeKind.Delegate || member.IsAbstract)
{
return false;
}

return member switch
{
// `return base.M(...)` is not valid for a by-ref return, so those keep the stub.
IMethodSymbol method => method.RefKind == RefKind.None,
// An init-only accessor cannot assign through `wraps`, so it keeps the stub even though
// the getter would forward.
IPropertySymbol property => property.SetMethod?.IsInitOnly != true,
_ => false,
};
}

/// <summary>
/// Returns a human-readable reason when the current compilation cannot host the
/// ref-struct pipeline, or <see langword="null" /> when it is supported. Both the target
Expand Down Expand Up @@ -294,10 +344,28 @@ private static bool NeedsRefStructPipeline(ITypeSymbol type)
private static bool TryGetRefStructIssue(IMethodSymbol method, string? pipelineUnsupportedReason,
out string? issue, bool isDelegate = false)
{
// Reported ahead of the parameter checks below, mirroring the generator's branch order: a
// ref-struct return is out of scope on every target, so naming it is more actionable than
// telling the user to upgrade to .NET 9 for a parameter that would still not be enough.
// Span/ReadOnlySpan returns go through the wrapper and are fine.
if (NeedsRefStructPipeline(method.ReturnType))
{
// The generator takes the same branch first, so a forwarding method's ref-struct
// parameters never reach the pipeline either and must not be reported below.
if (ForwardsToBaseImplementation(method))
{
issue = null;
return false;
}

issue = "methods returning a non-span ref struct are not supported";
return true;
}

bool hasRefStructParam = false;
foreach (IParameterSymbol p in method.Parameters)
{
if (!NeedsRefStructPipeline(p.Type))
if (!ParameterNeedsRefStructPipeline(p))
{
continue;
}
Expand Down Expand Up @@ -326,21 +394,29 @@ private static bool TryGetRefStructIssue(IMethodSymbol method, string? pipelineU
// Source/Mockolate/Setup/; arity 5+ are emitted by the generator into
// RefStructMethodSetups.g.cs.

// Ref-struct returns are out of scope unless they go through the Span wrapper.
if (NeedsRefStructPipeline(method.ReturnType))
{
issue = "methods returning a non-span ref struct are not supported";
return true;
}

issue = null;
return false;
}

private static bool TryGetRefStructIssueForIndexer(IPropertySymbol indexer, string? pipelineUnsupportedReason,
out string? issue)
{
if (!indexer.Parameters.Any(p => NeedsRefStructPipeline(p.Type)))
// The value type is out of scope regardless of the keys: IIndexerGetterOnlySetup<TValue, ...>
// stores a Func<TValue>, so TValue carries no 'allows ref struct' anti-constraint.
if (NeedsRefStructPipeline(indexer.Type))
{
// As for methods, a forwarding indexer never routes its keys through the pipeline.
if (ForwardsToBaseImplementation(indexer))
{
issue = null;
return false;
}

issue = "indexers returning a non-span ref struct are not supported";
return true;
}

if (!indexer.Parameters.Any(ParameterNeedsRefStructPipeline))
{
issue = null;
return false;
Expand Down
6 changes: 3 additions & 3 deletions Source/Mockolate.Analyzers/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,12 @@
<value>Mock arguments must be mockable</value>
</data>
<data name="Mockolate0003Description" xml:space="preserve">
<value>Methods with out/ref ref-struct parameters or non-span ref-struct return types cannot be mocked. Ref-struct parameter mocking (including ref-struct-keyed indexers) additionally requires .NET 9 or later.</value>
<value>Members that use a non-span ref struct in a value position - a method or delegate return, a property type, an indexer value - cannot be mocked, and neither can ref-struct parameters on delegate types. Ref-struct parameter mocking (including ref-struct-keyed indexers) is supported, but requires .NET 9 or later.</value>
</data>
<data name="Mockolate0003MessageFormat" xml:space="preserve">
<value>Type '{0}' has a method '{1}' that cannot be mocked: {2}</value>
<value>Type '{0}' has a member '{1}' that cannot be mocked: {2}</value>
</data>
<data name="Mockolate0003Title" xml:space="preserve">
<value>Ref-struct parameter mocking is not supported on this compilation</value>
<value>Ref-struct usage is not supported on this compilation</value>
</data>
</root>
21 changes: 21 additions & 0 deletions Source/Mockolate.SourceGenerators/Entities/Method.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public Method(IMethodSymbol methodSymbol, List<Method>? alreadyDefinedMethods, I
IsInitOnly = methodSymbol.IsInitOnly;
IsRefReturn = methodSymbol.RefKind == RefKind.Ref;
IsRefReadonlyReturn = methodSymbol.RefKind == RefKind.RefReadOnly;
IsDelegateInvoke = methodSymbol.MethodKind == MethodKind.DelegateInvoke;
ReturnType = methodSymbol.ReturnsVoid ? Type.Void : Type.From(methodSymbol.ReturnType);
Name = Helpers.EscapeIfKeyword(methodSymbol.ExplicitInterfaceImplementations.Length > 0 ? methodSymbol.ExplicitInterfaceImplementations[0].Name : methodSymbol.Name);
ContainingType = methodSymbol.ContainingType.ToDisplayString(Helpers.TypeDisplayFormat);
Expand Down Expand Up @@ -63,6 +64,26 @@ public Method(IMethodSymbol methodSymbol, List<Method>? alreadyDefinedMethods, I
public bool IsInitOnly { get; }
public bool IsRefReturn { get; }
public bool IsRefReadonlyReturn { get; }
public bool IsDelegateInvoke { get; }

/// <summary>
/// Delegates have no ref-struct setup pipeline: a delegate mock projects its single <c>Invoke</c>
/// onto <c>VoidMethodSetup&lt;T&gt;</c> / <c>ReturnMethodSetup&lt;T&gt;</c>, neither of which carries
/// the <c>allows ref struct</c> anti-constraint that the interface and class pipelines get from
/// <c>RefStructVoidMethodSetup&lt;T&gt;</c>. Such delegates get a <c>NotSupportedException</c> stub
/// and no setup/verify surface; <c>Span&lt;T&gt;</c>/<c>ReadOnlySpan&lt;T&gt;</c> parameters are
/// exempt because they flow through their wrappers.
/// </summary>
/// <remarks>
/// Deliberately the <c>MethodParameter</c> overload of <c>NeedsRefStructPipeline</c>, so
/// <c>ref readonly</c> Span/ReadOnlySpan is caught too: that ref kind has no wrapper-based emit
/// branch, so <c>AppendMethodSetupDefinition</c> would hand it the .NET 9-gated
/// <c>IRefStruct*Setup</c> surface while the delegate's <c>Invoke</c> body dispatches through
/// <c>VoidMethodSetup&lt;SpanWrapper&lt;T&gt;&gt;</c> - a setup the mock could never match.
/// </remarks>
public bool IsDelegateWithUnsupportedRefStructParameter
=> IsDelegateInvoke && Parameters.Any(parameter => parameter.NeedsRefStructPipeline());

public bool IsProtected => Accessibility is Accessibility.Protected or Accessibility.ProtectedOrInternal
or Accessibility.ProtectedAndInternal;

Expand Down
24 changes: 18 additions & 6 deletions Source/Mockolate.SourceGenerators/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,24 @@ public static string ToTypeOrWrapper(this Type type)
/// type AND the existing Span/ReadOnlySpan wrapper fallback doesn't apply.
/// </summary>
/// <remarks>
/// <c>System.Span&lt;T&gt;</c> and <c>System.ReadOnlySpan&lt;T&gt;</c> are themselves
/// ref-like, but the generator already boxes them into <c>SpanWrapper&lt;T&gt;</c> /
/// <c>ReadOnlySpanWrapper&lt;T&gt;</c> (a plain class), so their setup flows through the
/// regular <c>VoidMethodSetup</c> hierarchy with a non-ref-struct <c>T</c>. Only types
/// outside that wrapping need the <c>RefStructVoidMethodSetup</c> /
/// <c>RefStructReturnMethodSetup</c> / <c>RefStructIndexerGetterSetup</c> path.
/// <para>
/// <c>System.Span&lt;T&gt;</c> and <c>System.ReadOnlySpan&lt;T&gt;</c> are themselves
/// ref-like, but the generator already boxes them into <c>SpanWrapper&lt;T&gt;</c> /
/// <c>ReadOnlySpanWrapper&lt;T&gt;</c> (a plain class), so their setup flows through the
/// regular <c>VoidMethodSetup</c> hierarchy with a non-ref-struct <c>T</c>. Only types
/// outside that wrapping need the <c>RefStructVoidMethodSetup</c> /
/// <c>RefStructReturnMethodSetup</c> / <c>RefStructIndexerGetterSetup</c> path.
/// </para>
/// <para>
/// In a <i>value</i> position - a method or delegate return, a property type, an indexer
/// value - there is no such pipeline to route into: those positions parameterize types that
/// store a <c>Func&lt;T&gt;</c> (<c>IReturnMethodSetup&lt;T&gt;</c>,
/// <c>IPropertyGetterOnlySetup&lt;T&gt;</c>, <c>IIndexerGetterOnlySetup&lt;TValue, ...&gt;</c>),
/// which is illegal for a ref struct, so they cannot carry the <c>allows ref struct</c>
/// anti-constraint the parameter positions use. Members matching this in a value position get
/// no setup/verify surface: a virtual class member forwards to the wrapped instance or to
/// <c>base</c>, everything else gets a <c>NotSupportedException</c> stub.
/// </para>
/// </remarks>
public static bool NeedsRefStructPipeline(this Type type)
=> type.IsRefStruct
Expand Down
Loading
Loading