Skip to content

fix: support ref structs in value positions - #868

Merged
vbreuss merged 8 commits into
mainfrom
fix/ref-struct-value-positions
Sep 16, 2026
Merged

vbreuss merged 8 commits into
mainfrom
fix/ref-struct-value-positions

Conversation

@vbreuss

@vbreuss vbreuss commented Sep 15, 2026

Copy link
Copy Markdown
Member

A ref struct reaching a value position (method or delegate return, property type, indexer value, event argument) parameterized a generic that stores a Func and so cannot carry the allows ref struct anti-constraint, making the generated mock fail to compile with CS9244. Only parameter positions had been routed through the ref-struct pipeline; value positions were never gated, so instead of the intended graceful degradation the user got a raw compiler error on generated code they cannot edit.

Events and Span-typed properties are now genuinely supported: the IDefaultEventParameters raise overload is omitted for ref-struct arguments (it fills parameters via Generate, whose body type-tests an object against T and therefore cannot be anti-constrained) while the strongly-typed overload passes the value straight to the backing delegate, and property setup/verify facades now use the SpanWrapper carve-out the property body and the indexer facades already used, which also fixes the latent mismatch between a setup stored as Span and a body reading SpanWrapper.

The remaining shapes have no wrapper to route through and stay unsupported by design, so they degrade to the NotSupportedException stub already used for ref-struct-returning methods rather than breaking the build: non-span ref-struct returns on methods and delegates, ref-struct parameters on delegates, ref-struct properties, and ref-struct indexer values.

MockabilityAnalyzer had the matching blind spots - it flagged ref-struct method returns and indexer keys but not properties or indexer values - so every unsupported shape now reports Mockolate0003 at the CreateMock() call site instead of silently producing a throwing member.

The delegate Invoke body is extracted into its own method so the guard stays small; the move is otherwise verbatim.

A ref struct reaching a value position (method or delegate return, property type, indexer value, event argument) parameterized a generic that stores a Func<T> and so cannot carry the allows ref struct anti-constraint, making the generated mock fail to compile with CS9244. Only parameter positions had been routed through the ref-struct pipeline; value positions were never gated, so instead of the intended graceful degradation the user got a raw compiler error on generated code they cannot edit.

Events and Span-typed properties are now genuinely supported: the IDefaultEventParameters raise overload is omitted for ref-struct arguments (it fills parameters via Generate<T>, whose body type-tests an object against T and therefore cannot be anti-constrained) while the strongly-typed overload passes the value straight to the backing delegate, and property setup/verify facades now use the SpanWrapper carve-out the property body and the indexer facades already used, which also fixes the latent mismatch between a setup stored as Span<T> and a body reading SpanWrapper<T>.

The remaining shapes have no wrapper to route through and stay unsupported by design, so they degrade to the NotSupportedException stub already used for ref-struct-returning methods rather than breaking the build: non-span ref-struct returns on methods and delegates, ref-struct parameters on delegates, ref-struct properties, and ref-struct indexer values.

MockabilityAnalyzer had the matching blind spots - it flagged ref-struct method returns and indexer keys but not properties or indexer values - so every unsupported shape now reports Mockolate0003 at the CreateMock() call site instead of silently producing a throwing member.

The delegate Invoke body is extracted into its own method so the guard stays small; the move is otherwise verbatim.
@vbreuss vbreuss self-assigned this Sep 15, 2026
Copilot AI lite review requested due to automatic review settings September 15, 2026 08:37
@vbreuss vbreuss added the bug Something isn't working label Sep 15, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Test Results

    24 files  ±  0      24 suites  ±0   10m 1s ⏱️ - 1m 36s
 4 522 tests + 71   4 520 ✅ + 71  2 💤 ±0  0 ❌ ±0 
28 635 runs  +394  28 631 ✅ +394  4 💤 ±0  0 ❌ ±0 

Results for commit 0f16098. ± Comparison against base commit 1995c72.

This pull request removes 1 and adds 72 tests. Note that renamed tests count towards both.
Mockolate.SourceGenerators.Tests.MockTests+DelegateTests ‑ CustomDelegates_ShouldSupportSpanAndReadOnlySpanParameters
Mockolate.Analyzers.Tests.MockabilityAnalyzerRefStructTests ‑ WhenLanguageVersionBelowCSharp13_RefReadonlySpanParameterMethod_ShouldBeFlagged
Mockolate.Analyzers.Tests.MockabilityAnalyzerRefStructTests ‑ WhenMockingAbstractClassWithRefStructProperty_ShouldBeFlagged
Mockolate.Analyzers.Tests.MockabilityAnalyzerRefStructTests ‑ WhenMockingClassWithVirtualInitOnlyRefStructProperty_ShouldBeFlagged
Mockolate.Analyzers.Tests.MockabilityAnalyzerRefStructTests ‑ WhenMockingClassWithVirtualRefReturningRefStructMethod_ShouldBeFlagged
Mockolate.Analyzers.Tests.MockabilityAnalyzerRefStructTests ‑ WhenMockingClassWithVirtualRefStructIndexer_ShouldNotBeFlagged
Mockolate.Analyzers.Tests.MockabilityAnalyzerRefStructTests ‑ WhenMockingClassWithVirtualRefStructProperty_ShouldNotBeFlagged
Mockolate.Analyzers.Tests.MockabilityAnalyzerRefStructTests ‑ WhenMockingClassWithVirtualRefStructReturnAndParameter_ShouldNotBeFlagged
Mockolate.Analyzers.Tests.MockabilityAnalyzerRefStructTests ‑ WhenMockingClassWithVirtualRefStructReturningMethod_ShouldNotBeFlagged
Mockolate.Analyzers.Tests.MockabilityAnalyzerRefStructTests ‑ WhenMockingDelegateWithRefReadonlySpanParameter_ShouldBeFlagged
Mockolate.Analyzers.Tests.MockabilityAnalyzerRefStructTests ‑ WhenMockingDelegateWithSpanParameter_ShouldNotBeFlagged
…

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 2.71GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Indexer N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 866.5 ns 4.24 ns 3.97 ns 0.87 3.77 KB 1.00
Mockolate 1 998.4 ns 21.35 ns 19.97 ns 1.00 3.77 KB 1.00
Imposter 1 971.3 ns 18.00 ns 16.84 ns 0.97 5.16 KB 1.37
Moq 1 222,227.8 ns 760.16 ns 673.86 ns 222.67 20.37 KB 5.41
NSubstitute 1 10,914.3 ns 30.18 ns 28.23 ns 10.94 12.78 KB 3.39
FakeItEasy 1 12,514.6 ns 156.98 ns 139.15 ns 12.54 13.63 KB 3.62
baseline* 10 2,383.8 ns 6.36 ns 5.95 ns 0.96 4.82 KB 1.00
Mockolate 10 2,483.2 ns 5.77 ns 5.12 ns 1.00 4.82 KB 1.00
Imposter 10 2,144.4 ns 16.55 ns 15.48 ns 0.86 7.97 KB 1.65
Moq 10 232,702.1 ns 3,399.45 ns 3,179.85 ns 93.71 29.89 KB 6.20
NSubstitute 10 25,350.9 ns 100.05 ns 93.59 ns 10.21 25.58 KB 5.31
FakeItEasy 10 26,455.7 ns 111.28 ns 104.09 ns 10.65 32.97 KB 6.84
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Property N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 485.9 ns 15.96 ns 14.93 ns 0.95 2.41 KB 1.00
Mockolate 1 512.3 ns 10.06 ns 9.41 ns 1.00 2.41 KB 1.00
Imposter 1 446.7 ns 2.89 ns 2.56 ns 0.87 3.13 KB 1.29
TUnitMocks 1 450.1 ns 1.19 ns 1.11 ns 0.88 1.64 KB 0.68
Moq 1 10,285.0 ns 38.88 ns 32.46 ns 20.08 10.39 KB 4.30
NSubstitute 1 6,854.9 ns 62.43 ns 58.40 ns 13.39 11.45 KB 4.74
FakeItEasy 1 7,316.9 ns 174.47 ns 163.20 ns 14.29 11.24 KB 4.66
baseline* 10 985.7 ns 15.18 ns 14.20 ns 0.95 2.91 KB 1.00
Mockolate 10 1,032.2 ns 24.69 ns 23.10 ns 1.00 2.91 KB 1.00
Imposter 10 1,121.5 ns 7.19 ns 6.72 ns 1.09 4.67 KB 1.61
TUnitMocks 10 1,620.9 ns 22.16 ns 20.73 ns 1.57 3.94 KB 1.35
Moq 10 17,093.0 ns 151.32 ns 141.54 ns 16.57 18.28 KB 6.29
NSubstitute 10 16,537.7 ns 191.44 ns 169.71 ns 16.03 21.08 KB 7.25
FakeItEasy 10 17,524.0 ns 136.83 ns 127.99 ns 16.98 30.81 KB 10.60
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 9V74 2.79GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Event Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 309.6 ns 12.60 ns 11.79 ns 0.80 1.7 KB 1.00
Mockolate 384.8 ns 4.07 ns 3.81 ns 1.00 1.7 KB 1.00
Imposter 1,319.9 ns 5.72 ns 5.07 ns 3.43 8.8 KB 5.17
TUnitMocks 173.8 ns 0.89 ns 0.79 ns 0.45 1.34 KB 0.79
Moq 13,826.9 ns 116.15 ns 108.64 ns 35.93 12.51 KB 7.34
NSubstitute 5,230.4 ns 24.16 ns 21.42 ns 13.59 9.05 KB 5.31
FakeItEasy 231,522.9 ns 656.09 ns 581.61 ns 601.69 15.26 KB 8.96
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 9V74 3.69GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

CreateMock Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 17.75 ns 0.622 ns 0.581 ns 1.30 160 B 1.00
Mockolate 13.66 ns 0.264 ns 0.247 ns 1.00 160 B 1.00
Imposter 218.72 ns 4.887 ns 4.571 ns 16.01 2248 B 14.05
TUnitMocks 30.19 ns 0.553 ns 0.462 ns 2.21 200 B 1.25
Moq 1,049.54 ns 10.030 ns 9.382 ns 76.84 2096 B 13.10
NSubstitute 1,478.34 ns 17.830 ns 15.806 ns 108.23 5048 B 31.55
FakeItEasy 1,575.00 ns 30.645 ns 28.666 ns 115.31 2763 B 17.27
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Callback Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 314.8 ns 4.81 ns 4.02 ns 0.97 1.57 KB 1.00
Mockolate 325.7 ns 11.24 ns 10.51 ns 1.00 1.57 KB 1.00
Imposter 411.3 ns 8.23 ns 7.69 ns 1.26 2.38 KB 1.52
TUnitMocks 500.5 ns 11.65 ns 10.90 ns 1.54 1.99 KB 1.27
Moq 100,215.0 ns 497.85 ns 415.73 ns 307.99 8.88 KB 5.66
NSubstitute 4,743.6 ns 46.50 ns 43.49 ns 14.58 7.71 KB 4.91
FakeItEasy 4,875.1 ns 64.22 ns 56.93 ns 14.98 6.81 KB 4.33
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat)
AMD EPYC 7763 2.66GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.401
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Method N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 402.1 ns 17.20 ns 15.25 ns 1.13 1.93 KB 1.00
Mockolate 1 356.7 ns 2.86 ns 2.54 ns 1.00 1.93 KB 1.00
Imposter 1 533.2 ns 3.24 ns 2.87 ns 1.49 4.04 KB 2.09
TUnitMocks 1 464.9 ns 3.04 ns 2.85 ns 1.30 2.02 KB 1.04
Moq 1 186,963.1 ns 464.00 ns 411.32 ns 524.14 14.58 KB 7.55
NSubstitute 1 5,989.8 ns 15.89 ns 14.86 ns 16.79 9.06 KB 4.70
FakeItEasy 1 5,925.1 ns 25.75 ns 20.11 ns 16.61 8.05 KB 4.17
baseline* 10 737.1 ns 9.44 ns 8.37 ns 1.13 2.14 KB 1.00
Mockolate 10 651.0 ns 5.63 ns 5.27 ns 1.00 2.14 KB 1.00
Imposter 10 1,120.2 ns 6.75 ns 6.31 ns 1.72 5.52 KB 2.58
TUnitMocks 10 1,347.3 ns 6.07 ns 5.07 ns 2.07 3.73 KB 1.74
Moq 10 191,096.4 ns 478.77 ns 399.80 ns 293.55 18.48 KB 8.63
NSubstitute 10 8,911.1 ns 50.77 ns 47.49 ns 13.69 11.52 KB 5.38
FakeItEasy 10 9,207.9 ns 39.65 ns 33.11 ns 14.14 15.42 KB 7.20

baseline* rows show the corresponding Mockolate benchmark from the most recent successful main branch build with results, for regression comparison.

A method that both returned and took a non-span ref struct was routed into the ref-struct parameter pipeline, which #errors below .NET 9, so a member that can never be supported anyway broke the whole compilation instead of degrading to a stub. The return type now decides first, in the generator and the analyzer alike, because it disqualifies the member regardless of target.

Verify now follows Setup for ref-struct returns: the body is a NotSupportedException stub, so the call never reaches interaction recording and the surface could only ever report zero - which is already why properties and indexers drop it.

Mockolate0003 called every member a "method" while reporting on properties and indexers, and its description still claimed out/ref ref-struct parameters were unmockable; the docs carried the same stale list and neither new value position. IsUnsupportedRefStructValue was a pure alias for NeedsRefStructPipeline, so a change to either would have silently redefined the other.

The tests were gated to net9.0+ even though none of these shapes are, hiding the targets where the old code broke hardest, and left five warnings in every build.
Copilot AI review requested due to automatic review settings September 15, 2026 09:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

… usable

A Span-valued indexer setter inferred `ApplyIndexerSetter<Span<T>>` and failed with CS9244, so the whole mock stopped compiling with no diagnostic pointing at it. The value type now goes through the same wrapper the getter and the property pipeline already use.

A ref struct in a value position still has no setup surface, but that is no reason to break a member that works: a virtual class member forwards to the wrapped instance or to base instead of throwing, which only interface and abstract members now do. A ref-struct return also no longer claims a fast-interaction buffer it can never record into.
The list still claimed `out`/`ref`/`ref readonly` ref-struct parameters are rejected, while the analyzer page this section is linked from as the authority says they are supported. It also predates the value-position and delegate-parameter carve-outs.
Copilot AI review requested due to automatic review settings September 15, 2026 09:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…members

Span-valued properties and indexers were compilable but silently unverifiable: the value is matched through `EqualityComparer<SpanWrapper<T>>.Default`, and the wrapper had no `Equals`, so the by-value `Set(someSpan)` overload compiled and never matched. Content equality on both wrappers makes the form a user reaches for first work, rather than removing it in favour of the predicate matchers; `ToString` is there because recorded interactions render the wrapper, and the type name alone made the failure message useless.

A `virtual` class member with a ref struct in a value position keeps working - the override forwards to the wrapped instance or to `base` - so warning that it "cannot be mocked" only forced a suppression on code that needs no fix. The analyzer now mirrors the generator's forwarding condition, which also suppresses the parameter diagnostic for those members: the generator takes the return branch first, so their ref-struct parameters never reach the pipeline either.

The by-ref-return storage field was emitted before the body was known to be a stub, so a ref-struct return made the field itself illegal (CS8345) and broke the whole mock over a member that was already given up on - the opposite of what degrading is for.

The behaviour flags the passthrough bypasses are now written down instead of inferred: with no setup to honour them against, `SkipBaseClass` has nothing to return in the base call's place and `ThrowWhenNotSetup` would reject a member that can never be set up.

Tests cover the value-position accessors that had none: the setter paths on interfaces and classes, protected accessors, init-only getters, by-ref returns, and Span-typed members on classes rather than only on interfaces.
Copilot AI review requested due to automatic review settings September 15, 2026 20:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The analyzer only ever modelled the type-level half of the generator's ref-struct rule, so `ref readonly Span<T>` slipped past it. That ref kind is the one span shape without a wrapper-based emit branch, so the generator routes it through the ref-struct pipeline like a custom ref struct: on a delegate it degrades to a NotSupportedException stub, and on an interface or class it needs .NET 9 / C# 13 or the emitted source hits an #error. Neither said anything at compile time.

Mirroring the parameter-level predicate in a deliberately distinct name, rather than a second overload, since the two rules disagree and silently picking the wrong one is what caused this.
Copilot AI review requested due to automatic review settings September 16, 2026 04:29

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The ref-struct remark on AddRefStructMethodBody still listed out/ref ref-struct parameters and arity above 4 as out of scope, both of which that same method has supported for a while - the write-back block sits 55 lines below the claim, and this branch already corrected the user-facing docs.

Nothing covered a delegate with a by-value Span parameter, which is the exact boundary IsDelegateWithUnsupportedRefStructParameter draws: widen it to Type.IsRefStruct and the whole setup/verify surface silently disappears. The generator test that looked like it covered this was asserting Span return types instead, so it is renamed to say so.
Copilot AI review requested due to automatic review settings September 16, 2026 05:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sonarqubecloud

Copy link
Copy Markdown

@vbreuss
vbreuss merged commit 265babd into main Sep 16, 2026
18 checks passed
@vbreuss
vbreuss deleted the fix/ref-struct-value-positions branch September 16, 2026 06:16
github-actions Bot added a commit that referenced this pull request Sep 16, 2026
github-actions Bot added a commit that referenced this pull request Sep 16, 2026
@github-actions

Copy link
Copy Markdown

This is addressed in release v3.5.1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working state: released The issue is released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants