From d90a6dce04b0b6f4518555f2eeb393d804c01b74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 15 Sep 2026 09:06:42 +0200 Subject: [PATCH 1/8] fix: support ref structs in value positions 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. --- .../MockabilityAnalyzer.cs | 16 + .../Entities/Method.cs | 14 + Source/Mockolate.SourceGenerators/Helpers.cs | 15 + .../Sources/Sources.MockClass.cs | 116 +++++-- .../Sources/Sources.MockDelegate.cs | 294 ++++++++++-------- .../MockabilityAnalyzerRefStructTests.cs | 112 +++++++ .../RefStruct/RefStructValuePositionTests.cs | 173 +++++++++++ 7 files changed, 579 insertions(+), 161 deletions(-) create mode 100644 Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs diff --git a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs index a197dee30..63b954716 100644 --- a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs +++ b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs @@ -151,6 +151,14 @@ when TryGetRefStructIssueForIndexer(p, pipelineUnsupportedReason, out string? is "this[]", issue)); break; + case IPropertySymbol { IsIndexer: false, } p when NeedsRefStructPipeline(p.Type): + context.ReportDiagnostic(Diagnostic.Create( + s_refStructRule, + location, + type.ToDisplayString(), + p.Name, + "properties of a non-span ref struct type are not supported")); + break; } } } @@ -340,6 +348,14 @@ private static bool TryGetRefStructIssue(IMethodSymbol method, string? pipelineU private static bool TryGetRefStructIssueForIndexer(IPropertySymbol indexer, string? pipelineUnsupportedReason, out string? issue) { + // The value type is out of scope regardless of the keys: IIndexerGetterOnlySetup + // stores a Func, so TValue carries no 'allows ref struct' anti-constraint. + if (NeedsRefStructPipeline(indexer.Type)) + { + issue = "indexers returning a non-span ref struct are not supported"; + return true; + } + if (!indexer.Parameters.Any(p => NeedsRefStructPipeline(p.Type))) { issue = null; diff --git a/Source/Mockolate.SourceGenerators/Entities/Method.cs b/Source/Mockolate.SourceGenerators/Entities/Method.cs index 8dff033f3..5785e02f4 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Method.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Method.cs @@ -19,6 +19,7 @@ public Method(IMethodSymbol methodSymbol, List? 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); @@ -63,6 +64,19 @@ public Method(IMethodSymbol methodSymbol, List? alreadyDefinedMethods, I public bool IsInitOnly { get; } public bool IsRefReturn { get; } public bool IsRefReadonlyReturn { get; } + public bool IsDelegateInvoke { get; } + + /// + /// Delegates have no ref-struct setup pipeline: a delegate mock projects its single Invoke + /// onto VoidMethodSetup<T> / ReturnMethodSetup<T>, neither of which carries + /// the allows ref struct anti-constraint that the interface and class pipelines get from + /// RefStructVoidMethodSetup<T>. Such delegates get a NotSupportedException stub + /// and no setup/verify surface; Span<T>/ReadOnlySpan<T> parameters are + /// exempt because they flow through their wrappers. + /// + public bool HasUnsupportedRefStructParameter + => IsDelegateInvoke && Parameters.Any(parameter => parameter.NeedsRefStructPipeline()); + public bool IsProtected => Accessibility is Accessibility.Protected or Accessibility.ProtectedOrInternal or Accessibility.ProtectedAndInternal; diff --git a/Source/Mockolate.SourceGenerators/Helpers.cs b/Source/Mockolate.SourceGenerators/Helpers.cs index 621a971e5..98fc2c79e 100644 --- a/Source/Mockolate.SourceGenerators/Helpers.cs +++ b/Source/Mockolate.SourceGenerators/Helpers.cs @@ -230,6 +230,21 @@ public static bool NeedsRefStructPipeline(this Type type) => type.IsRefStruct && type.SpecialGenericType is not (SpecialGenericType.Span or SpecialGenericType.ReadOnlySpan); + /// + /// Returns true if the type cannot occupy a value position - a method or delegate return, + /// a property type, or an indexer value - anywhere in the setup pipeline. + /// + /// + /// Value positions parameterize types that store a Func<T> (IReturnMethodSetup<T>, + /// IPropertyGetterOnlySetup<T>, IIndexerGetterOnlySetup<TValue, ...>), which is + /// illegal for a ref struct, so they cannot carry the allows ref struct anti-constraint the + /// parameter positions use. Span<T>/ReadOnlySpan<T> are exempt: they reach those + /// types as SpanWrapper<T>/ReadOnlySpanWrapper<T>. Members matching this get a + /// NotSupportedException stub and no setup/verify surface. + /// + public static bool IsUnsupportedRefStructValue(this Type type) + => type.NeedsRefStructPipeline(); + /// /// Returns true if the parameter must flow through the ref-struct setup pipeline. The /// overload excludes Span<T> and ReadOnlySpan<T> diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs index 77ba3ff8b..e7a5c83ee 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs @@ -1763,6 +1763,18 @@ property.IndexerParameters is not null bool isRefStructIndexer = property is { IsIndexer: true, IndexerParameters: not null, } && property.IndexerParameters.Value.Any(p => p.NeedsRefStructPipeline()); + // A non-span ref-struct property or indexer value has no setup surface either (see + // Helpers.IsUnsupportedRefStructValue), so both accessors throw for the same reason. + bool isUnsupportedRefStructValue = property.Type.IsUnsupportedRefStructValue(); + + void AppendUnsupportedRefStructValueThrow() + => sb.Append("\t\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: ") + .Append(property.IsIndexer + ? "indexers returning a non-span ref struct are not supported. Indexer '" + : "properties of a non-span ref struct type are not supported. Property '") + .Append(property.ContainingType).Append('.').Append(property.Name).Append("'.\");") + .AppendLine(); + sb.AppendLine("\t\t{"); if (property.Getter != null) { @@ -1775,7 +1787,11 @@ property.IndexerParameters is not null sb.AppendLine("get"); sb.AppendLine("\t\t\t{"); - if (isRefStructIndexer) + if (isUnsupportedRefStructValue) + { + AppendUnsupportedRefStructValueThrow(); + } + else if (isRefStructIndexer) { AppendRefStructIndexerGetterBody(sb, property, mockRegistry); } @@ -2010,9 +2026,13 @@ property.IndexerParameters is not null sb.AppendLine(property.Setter.IsInitOnly ? "init" : "set"); sb.AppendLine("\t\t\t{"); + if (isUnsupportedRefStructValue) + { + AppendUnsupportedRefStructValueThrow(); + } // Ref-struct-keyed indexer setter: dispatches through // RefStructIndexerSetterSetup. Mirrors the getter pipeline. - if (isRefStructIndexer) + else if (isRefStructIndexer) { AppendRefStructIndexerSetterBody(sb, property, mockRegistry); } @@ -2330,6 +2350,16 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb, return; } + // A non-span ref-struct return has no setup surface to route through (see + // Helpers.IsUnsupportedRefStructValue), so the body throws instead. The ref-struct-parameter + // branch above emits the same message from its own carve-out. + if (method.ReturnType.IsUnsupportedRefStructValue()) + { + AppendUnsupportedRefStructReturnThrow(sb, method); + sb.AppendLine("\t\t}"); + return; + } + string methodSetup = Helpers.GetUniqueLocalVariableName("methodSetup", method.Parameters); string methodSetupType = (method.ReturnType == Type.Void, method.Parameters.Count) switch { @@ -2732,10 +2762,7 @@ method.ReturnType.SpecialGenericType is not if (returnsUnsupportedRefStruct) { - sb.Append("\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: ") - .Append("methods returning a non-span ref struct are not supported") - .Append(". Method '").Append(method.ContainingType).Append('.') - .Append(method.Name).Append("'.\");").AppendLine(); + AppendUnsupportedRefStructReturnThrow(sb, method); sb.Append("#else").AppendLine(); sb.Append( "#error Mockolate: methods with ref-struct parameters require .NET 9 or later (uses the 'allows ref struct' anti-constraint).") @@ -3191,9 +3218,11 @@ private static PropertyAccessors GetInterceptedAccessors(Property property) private static string GetPropertySetupType(Property property) => GetInterceptedAccessors(property) switch { - PropertyAccessors.GetOnly => $"global::Mockolate.Setup.IPropertyGetterOnlySetup<{property.Type.Fullname}>", - PropertyAccessors.SetOnly => $"global::Mockolate.Setup.IPropertySetterOnlySetup<{property.Type.Fullname}>", - _ => $"global::Mockolate.Setup.PropertySetup<{property.Type.Fullname}>", + PropertyAccessors.GetOnly => + $"global::Mockolate.Setup.IPropertyGetterOnlySetup<{property.Type.ToTypeOrWrapper()}>", + PropertyAccessors.SetOnly => + $"global::Mockolate.Setup.IPropertySetterOnlySetup<{property.Type.ToTypeOrWrapper()}>", + _ => $"global::Mockolate.Setup.PropertySetup<{property.Type.ToTypeOrWrapper()}>", }; /// @@ -3206,8 +3235,8 @@ private static string GetPropertyVerifyType(Property property, string verifyName PropertyAccessors.GetOnly => $"global::Mockolate.Verify.VerificationPropertyGetterResult<{verifyName}>", PropertyAccessors.SetOnly => - $"global::Mockolate.Verify.VerificationPropertySetterResult<{verifyName}, {property.Type.Fullname}>", - _ => $"global::Mockolate.Verify.VerificationPropertyResult<{verifyName}, {property.Type.Fullname}>", + $"global::Mockolate.Verify.VerificationPropertySetterResult<{verifyName}, {property.Type.ToTypeOrWrapper()}>", + _ => $"global::Mockolate.Verify.VerificationPropertyResult<{verifyName}, {property.Type.ToTypeOrWrapper()}>", }; /// @@ -3266,7 +3295,7 @@ private static void DefineSetupInterface(StringBuilder sb, Class @class, MemberT Func propertyPredicate = property => property.ExplicitImplementation is null && property is { IsIndexer: false, } && - property.MemberType == memberType; + property.MemberType == memberType && !property.Type.IsUnsupportedRefStructValue(); foreach (Property property in @class.AllProperties().Where(propertyPredicate)) { sb.AppendXmlSummary( @@ -3296,7 +3325,7 @@ private static void DefineSetupInterface(StringBuilder sb, Class @class, MemberT Func indexerPredicate = indexer => indexer.ExplicitImplementation is null && indexer is { IsIndexer: true, } && - indexer.MemberType == memberType; + indexer.MemberType == memberType && !indexer.Type.IsUnsupportedRefStructValue(); foreach (Property indexer in @class.AllProperties().Where(indexerPredicate)) { AppendIndexerSetupDefinition(sb, indexer, hasOverloadResolutionPriority: hasOverloadResolutionPriority); @@ -3482,7 +3511,12 @@ private static void AppendMethodSetupDefinition(StringBuilder sb, Class @class, // Methods using a generic type parameter that declares `allows ref struct` cannot expose // a setup surface: IReturnMethodSetup / IVoidMethodSetup do not carry the same // anti-constraint. The override body throws NotSupportedException, so no setup is needed. - if (method.HasUnsupportedAllowsRefStructTypeParameter) + // A non-span ref-struct return is unsupported for the same reason, in both the regular and + // the ref-struct-parameter pipeline, so it is checked ahead of the branch below - as is a + // ref-struct parameter on a delegate, which has no ref-struct pipeline to route into. + if (method.HasUnsupportedAllowsRefStructTypeParameter || + method.ReturnType.IsUnsupportedRefStructValue() || + method.HasUnsupportedRefStructParameter) { return; } @@ -3701,7 +3735,7 @@ private static void ImplementSetupInterface(StringBuilder sb, Class @class, stri Func propertyPredicate = property => property.ExplicitImplementation is null && property is { IsIndexer: false, } && - property.MemberType == memberType; + property.MemberType == memberType && !property.Type.IsUnsupportedRefStructValue(); foreach (Property property in @class.AllProperties().Where(propertyPredicate)) { sb.Append("\t\t/// ").AppendLine(); @@ -3714,7 +3748,7 @@ private static void ImplementSetupInterface(StringBuilder sb, Class @class, stri sb.Append("\t\t\tget").AppendLine(); sb.Append("\t\t\t{").AppendLine(); sb.Append("\t\t\t\tvar propertySetup = new global::Mockolate.Setup.PropertySetup<") - .Append(property.Type.Fullname).Append(">(").Append(mockRegistryName).Append(", ") + .AppendTypeOrWrapper(property.Type).Append(">(").Append(mockRegistryName).Append(", ") .Append(property.GetUniqueNameString()).Append(");") .AppendLine(); sb.Append("\t\t\t\tthis.").Append(mockRegistryName).Append(".SetupProperty(") @@ -3760,7 +3794,7 @@ private static void ImplementSetupInterface(StringBuilder sb, Class @class, stri Func indexerPredicate = indexer => indexer.ExplicitImplementation is null && indexer is { IsIndexer: true, } && - indexer.MemberType == memberType; + indexer.MemberType == memberType && !indexer.Type.IsUnsupportedRefStructValue(); foreach (Property indexer in @class.AllProperties().Where(indexerPredicate)) { AppendIndexerSetupImplementation(sb, indexer, mockRegistryName, setupName, memberIds, memberIdPrefix, @@ -3857,9 +3891,12 @@ private static void AppendMethodSetupImplementation(StringBuilder sb, Method met string? scopeExpression = null, bool perElementParams = false) { // Setup-side carve-out: methods using a generic type parameter that declares - // `allows ref struct` have no setup interface declaration (see - // AppendMethodSetupDefinition), so no explicit implementation is emitted either. - if (method.HasUnsupportedAllowsRefStructTypeParameter) + // `allows ref struct`, methods returning a non-span ref struct, and delegates with a ref-struct + // parameter have no setup interface declaration (see AppendMethodSetupDefinition), so no + // explicit implementation is emitted either. + if (method.HasUnsupportedAllowsRefStructTypeParameter || + method.ReturnType.IsUnsupportedRefStructValue() || + method.HasUnsupportedRefStructParameter) { return; } @@ -5047,8 +5084,32 @@ private static void AppendIndexerVerifyParameterMatches(StringBuilder sb, #endregion Setup Helpers + /// + /// Emits the NotSupportedException body shared by every method whose return type is an + /// unsupported ref struct, so both carve-outs report the same message. + /// + private static void AppendUnsupportedRefStructReturnThrow(StringBuilder sb, Method method) + => sb.Append("\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: ") + .Append("methods returning a non-span ref struct are not supported") + .Append(". Method '").Append(method.ContainingType).Append('.') + .Append(method.Name).Append("'.\");").AppendLine(); + #region Raise Helpers + /// + /// Whether the IDefaultEventParameters overload can be emitted for . + /// + /// + /// The overload fills every delegate parameter through Generate<T>, which carries no + /// allows ref struct anti-constraint (its body type-tests an object against T), + /// so a ref-struct argument - Span<T> included, as the event path has no wrapper + /// carve-out - would fail with CS9244. Such events keep the strongly-typed Raise overload, + /// which passes the argument straight to the backing delegate. + /// + private static bool HasDefaultParametersRaiseOverload(Event @event) + => @event.Delegate.Parameters.Count > 0 && + !@event.Delegate.Parameters.Any(parameter => parameter.Type.IsRefStruct); + private static void DefineRaiseInterface(StringBuilder sb, Class @class, MemberType memberType) { Func predicate = @event => @event.ExplicitImplementation is null && @@ -5067,7 +5128,7 @@ private static void DefineRaiseInterface(StringBuilder sb, Class @class, MemberT .GroupBy(m => m.Name) .Where(g => g.Count() == 1) .Select(g => g.Single()) - .Where(m => m.Delegate.Parameters.Count > 0)) + .Where(HasDefaultParametersRaiseOverload)) { sb.AppendXmlSummary( $"Raise the event."); @@ -5120,7 +5181,7 @@ private static void ImplementRaiseInterface(StringBuilder sb, Class @class, stri .GroupBy(m => m.Name) .Where(g => g.Count() == 1) .Select(g => g.Single()) - .Where(m => m.Delegate.Parameters.Count > 0)) + .Where(HasDefaultParametersRaiseOverload)) { string backingFieldAccess = @event.IsStatic ? $"{@event.GetBackingFieldName()}.Value" @@ -5181,7 +5242,7 @@ private static void DefineVerifyInterface(StringBuilder sb, Class @class, string Func propertyPredicate = property => property.ExplicitImplementation is null && property is { IsIndexer: false, } && - property.MemberType == memberType; + property.MemberType == memberType && !property.Type.IsUnsupportedRefStructValue(); foreach (Property property in @class.AllProperties().Where(propertyPredicate)) { sb.AppendXmlSummary( @@ -5197,7 +5258,7 @@ private static void DefineVerifyInterface(StringBuilder sb, Class @class, string Func indexerPredicate = indexer => indexer.ExplicitImplementation is null && indexer is { IsIndexer: true, } && - indexer.MemberType == memberType; + indexer.MemberType == memberType && !indexer.Type.IsUnsupportedRefStructValue(); foreach (Property indexer in @class.AllProperties().Where(indexerPredicate)) { AppendIndexerVerifyDefinition(sb, indexer, verifyName, @@ -5446,7 +5507,7 @@ private static void ImplementVerifyInterface(StringBuilder sb, Class @class, str Func propertyPredicate = property => property.ExplicitImplementation is null && property is { IsIndexer: false, } && - property.MemberType == memberType; + property.MemberType == memberType && !property.Type.IsUnsupportedRefStructValue(); foreach (Property property in @class.AllProperties().Where(propertyPredicate)) { bool useFastForProperty = useFastBuffers && IsFastBufferEligibleProperty(property); @@ -5487,7 +5548,7 @@ private static void ImplementVerifyInterface(StringBuilder sb, Class @class, str Func indexerPredicate = indexer => indexer.ExplicitImplementation is null && indexer is { IsIndexer: true, IndexerParameters: not null, } && - indexer.MemberType == memberType; + indexer.MemberType == memberType && !indexer.Type.IsUnsupportedRefStructValue(); foreach (Property indexer in @class.AllProperties().Where(indexerPredicate)) { AppendIndexerVerifyImplementation(sb, indexer, mockRegistryName, verifyName, memberIds, memberIdPrefix, @@ -5968,7 +6029,8 @@ private static bool IsFastBufferEligibleIndexer(Property indexer) if (indexer.IsStatic || indexer.IndexerParameters is null || indexer.IndexerParameters.Value.Count == 0 || - indexer.IndexerParameters.Value.Count > 4) + indexer.IndexerParameters.Value.Count > 4 || + indexer.Type.IsUnsupportedRefStructValue()) { return false; } diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs index a750d8fdd..06bb74de9 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs @@ -58,140 +58,7 @@ public static string MockDelegate(string name, MockClass @class, Method delegate sb.Append("\t\tprivate ").Append(delegateMethod.ReturnType == Type.Void ? "void" : delegateMethod.ReturnType.Fullname).Append(" Invoke(") .Append(string.Join(", ", delegateMethod.Parameters.Select(p => $"{p.RefKind.GetString()}{p.Type.Fullname} {p.Name}"))).Append(')').AppendLine(); sb.Append("\t\t{").AppendLine(); - string methodSetup = Helpers.GetUniqueLocalVariableName("methodSetup", delegateMethod.Parameters); - string methodSetupType = (delegateMethod.ReturnType == Type.Void, delegateMethod.Parameters.Count) switch - { - (true, 0) => "global::Mockolate.Setup.VoidMethodSetup", - (true, _) => $"global::Mockolate.Setup.VoidMethodSetup<{string.Join(", ", delegateMethod.Parameters.Select(p => p.ToTypeOrWrapper()))}>", - (_, 0) => $"global::Mockolate.Setup.ReturnMethodSetup<{delegateMethod.ReturnType.ToTypeOrWrapper()}>", - (_, _) => $"global::Mockolate.Setup.ReturnMethodSetup<{delegateMethod.ReturnType.ToTypeOrWrapper()}, {string.Join(", ", delegateMethod.Parameters.Select(p => p.ToTypeOrWrapper()))}>", - }; - bool hasOutParams = delegateMethod.Parameters.Any(p => p.RefKind is RefKind.Out); - bool hasRefParams = delegateMethod.Parameters.Any(p => p.RefKind is RefKind.Ref); - string wpc = Helpers.GetUniqueLocalVariableName("wpc", delegateMethod.Parameters); - - StringBuilder sb2 = new(); - int i = 0; - foreach (MethodParameter p in delegateMethod.Parameters) - { - if (i++ > 0) - { - sb2.Append(", "); - } - - if (p.RefKind == RefKind.Ref) - { - string paramRef = Helpers.GetUniqueLocalVariableName($"ref_{p.Name}", delegateMethod.Parameters); - - 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) - { - string paramRef = Helpers.GetUniqueLocalVariableName($"ref_{p.Name}", delegateMethod.Parameters); - - sb.Append("\t\t\tvar ").Append(paramRef).Append(" = ").Append(p.ToNameOrWrapper()).Append(';').AppendLine(); - sb2.Append(paramRef); - } - else - { - sb2.Append(p.RefKind switch - { - RefKind.Out => "default", - _ => p.ToNameOrWrapper(), - }); - } - } - - string memberIdRef = memberIdPrefix + memberIds.GetMethodIdentifier(delegateMethod); - bool isGeneric = delegateMethod.GenericParameters is not null && - delegateMethod.GenericParameters.Value.Count > 0; - EmitFastMethodSetupLookup(sb, "\t\t\t", $"this.{mockRegistryName}", methodSetup, methodSetupType, - memberIdRef, delegateMethod.GetUniqueNameString(), sb2.ToString(), isGeneric); - - if (hasOutParams) - { - foreach (MethodParameter parameter in delegateMethod.Parameters.Where(p => p.RefKind == RefKind.Out)) - { - sb.Append("\t\t\t").Append(parameter.Name).Append(" = default!;").AppendLine(); - } - } - - if (hasOutParams || hasRefParams) - { - string outParamBase = Helpers.GetUniqueIndexedLocalVariableBase("outParam", delegateMethod.Parameters); - string refParamBase = Helpers.GetUniqueIndexedLocalVariableBase("refParam", delegateMethod.Parameters); - sb.Append("\t\t\tif (").Append(methodSetup).Append(" is ").Append(methodSetupType).Append(".WithParameterCollection ").Append(wpc).Append(')').AppendLine(); - sb.Append("\t\t\t{").AppendLine(); - int parameterIndex = 0; - foreach (MethodParameter parameter in delegateMethod.Parameters) - { - parameterIndex++; - if (parameter.RefKind == RefKind.Out) - { - sb.Append("\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{").AppendLine(); - sb.Append("\t\t\t\t\t").Append(parameter.Name).Append(" = ").AppendDefaultValueGeneratorFor(parameter.Type, $"this.{mockRegistryName}.Behavior.DefaultValue").Append(';').AppendLine(); - sb.Append("\t\t\t\t}").AppendLine(); - } - else if (parameter.RefKind == RefKind.Ref) - { - sb.Append("\t\t\t\tif (").Append(wpc).Append(".Parameter").Append(parameterIndex).Append(" is global::Mockolate.Parameters.IRefParameter<").Append(parameter.Type.ToTypeOrWrapper()).Append("> ").Append(refParamBase).Append(parameterIndex).Append(")").AppendLine(); - sb.Append("\t\t\t\t{").AppendLine(); - sb.Append("\t\t\t\t\t").Append(parameter.Name).Append(" = ").Append(refParamBase).Append(parameterIndex).Append(".GetValue(").Append(parameter.Name).Append(");").AppendLine(); - sb.Append("\t\t\t\t}").AppendLine(); - } - } - - sb.Append("\t\t\t}").AppendLine(); - } - - sb.Append("\t\t\tif (").Append(mockRegistryName).Append(".Behavior.SkipInteractionRecording == false)").AppendLine(); - sb.Append("\t\t\t{").AppendLine(); - sb.Append("\t\t\t\t").Append(mockRegistryName).Append(".RegisterInteraction(new global::Mockolate.Interactions.MethodInvocation"); - if (delegateMethod.Parameters.Count > 0) - { - sb.Append('<').Append(string.Join(", ", delegateMethod.Parameters.Select(p => p.ToTypeOrWrapper()))).Append('>'); - } - - sb.Append("(").Append(delegateMethod.GetUniqueNameString()); - if (delegateMethod.Parameters.Count > 0) - { - sb.Append(", ").Append(string.Join(", ", delegateMethod.Parameters.Select(p => p.ToNameOrWrapper()))); - } - - sb.Append("));").AppendLine(); - sb.Append("\t\t\t}").AppendLine(); - - string displayDelegateName = - $"{delegateMethod.ContainingType}.{delegateMethod.Name}({string.Join(", ", delegateMethod.Parameters.Select(p => p.Type.DisplayName))})"; - sb.Append("\t\t\tif (").Append(methodSetup).Append(" is null && this.").Append(mockRegistryName).Append(".Behavior.ThrowWhenNotSetup)").AppendLine(); - sb.Append("\t\t\t{").AppendLine(); - sb.Append("\t\t\t\tthrow new global::Mockolate.Exceptions.MockNotSetupException(\"The method '").Append(displayDelegateName).Append("' was invoked without prior setup.\");").AppendLine(); - sb.Append("\t\t\t}").AppendLine(); - - AppendTriggerCallbacks(sb, "\t\t\t", methodSetup, delegateMethod.Parameters); - - if (delegateMethod.ReturnType != Type.Void) - { - string returnValue = Helpers.GetUniqueLocalVariableName("returnValue", delegateMethod.Parameters); - sb.Append("\t\t\treturn ").Append(methodSetup).Append("?.TryGetReturnValue("); - if (delegateMethod.Parameters.Count > 0) - { - sb.Append(string.Join(", ", delegateMethod.Parameters.Select(p => p.ToNameOrWrapper()))).Append(", "); - } - - sb.Append("out var ").Append(returnValue).Append(") == true ? ").Append(returnValue).Append(" : ") - .AppendDefaultValueGeneratorFor(delegateMethod.ReturnType, $"this.{mockRegistryName}.Behavior.DefaultValue") - .Append(';').AppendLine(); - } - + AppendMockDelegateInvokeBody(sb, delegateMethod, mockRegistryName, memberIds, memberIdPrefix); sb.Append("\t\t}").AppendLine(); sb.AppendLine(); @@ -504,4 +371,163 @@ public static string MockDelegate(string name, MockClass @class, Method delegate sb.AppendLine("#nullable disable annotations"); return sb.ToString(); } + + /// + /// Emits the body of the delegate mock's Invoke method. + /// + private static void AppendMockDelegateInvokeBody(StringBuilder sb, Method delegateMethod, + string mockRegistryName, MemberIdTable memberIds, string memberIdPrefix) + { + // A non-span ref-struct return cannot parameterize ReturnMethodSetup, and a ref-struct + // parameter cannot parameterize VoidMethodSetup. Either way the delegate gets no setup or + // verify surface (AppendMethodSetupDefinition skips it) and Invoke throws. + if (delegateMethod.ReturnType.IsUnsupportedRefStructValue()) + { + AppendUnsupportedRefStructReturnThrow(sb, delegateMethod); + return; + } + + if (delegateMethod.HasUnsupportedRefStructParameter) + { + sb.Append("\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: ") + .Append("ref-struct parameters are not supported on delegate types") + .Append(". Delegate '").Append(delegateMethod.ContainingType).Append("'.\");").AppendLine(); + return; + } + + string methodSetup = Helpers.GetUniqueLocalVariableName("methodSetup", delegateMethod.Parameters); + string methodSetupType = (delegateMethod.ReturnType == Type.Void, delegateMethod.Parameters.Count) switch + { + (true, 0) => "global::Mockolate.Setup.VoidMethodSetup", + (true, _) => $"global::Mockolate.Setup.VoidMethodSetup<{string.Join(", ", delegateMethod.Parameters.Select(p => p.ToTypeOrWrapper()))}>", + (_, 0) => $"global::Mockolate.Setup.ReturnMethodSetup<{delegateMethod.ReturnType.ToTypeOrWrapper()}>", + (_, _) => $"global::Mockolate.Setup.ReturnMethodSetup<{delegateMethod.ReturnType.ToTypeOrWrapper()}, {string.Join(", ", delegateMethod.Parameters.Select(p => p.ToTypeOrWrapper()))}>", + }; + bool hasOutParams = delegateMethod.Parameters.Any(p => p.RefKind is RefKind.Out); + bool hasRefParams = delegateMethod.Parameters.Any(p => p.RefKind is RefKind.Ref); + string wpc = Helpers.GetUniqueLocalVariableName("wpc", delegateMethod.Parameters); + + StringBuilder sb2 = new(); + int i = 0; + foreach (MethodParameter p in delegateMethod.Parameters) + { + if (i++ > 0) + { + sb2.Append(", "); + } + + if (p.RefKind == RefKind.Ref) + { + string paramRef = Helpers.GetUniqueLocalVariableName($"ref_{p.Name}", delegateMethod.Parameters); + + 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) + { + string paramRef = Helpers.GetUniqueLocalVariableName($"ref_{p.Name}", delegateMethod.Parameters); + + sb.Append("\t\t\tvar ").Append(paramRef).Append(" = ").Append(p.ToNameOrWrapper()).Append(';').AppendLine(); + sb2.Append(paramRef); + } + else + { + sb2.Append(p.RefKind switch + { + RefKind.Out => "default", + _ => p.ToNameOrWrapper(), + }); + } + } + + string memberIdRef = memberIdPrefix + memberIds.GetMethodIdentifier(delegateMethod); + bool isGeneric = delegateMethod.GenericParameters is not null && + delegateMethod.GenericParameters.Value.Count > 0; + EmitFastMethodSetupLookup(sb, "\t\t\t", $"this.{mockRegistryName}", methodSetup, methodSetupType, + memberIdRef, delegateMethod.GetUniqueNameString(), sb2.ToString(), isGeneric); + + if (hasOutParams) + { + foreach (MethodParameter parameter in delegateMethod.Parameters.Where(p => p.RefKind == RefKind.Out)) + { + sb.Append("\t\t\t").Append(parameter.Name).Append(" = default!;").AppendLine(); + } + } + + if (hasOutParams || hasRefParams) + { + string outParamBase = Helpers.GetUniqueIndexedLocalVariableBase("outParam", delegateMethod.Parameters); + string refParamBase = Helpers.GetUniqueIndexedLocalVariableBase("refParam", delegateMethod.Parameters); + sb.Append("\t\t\tif (").Append(methodSetup).Append(" is ").Append(methodSetupType).Append(".WithParameterCollection ").Append(wpc).Append(')').AppendLine(); + sb.Append("\t\t\t{").AppendLine(); + int parameterIndex = 0; + foreach (MethodParameter parameter in delegateMethod.Parameters) + { + parameterIndex++; + if (parameter.RefKind == RefKind.Out) + { + sb.Append("\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{").AppendLine(); + sb.Append("\t\t\t\t\t").Append(parameter.Name).Append(" = ").AppendDefaultValueGeneratorFor(parameter.Type, $"this.{mockRegistryName}.Behavior.DefaultValue").Append(';').AppendLine(); + sb.Append("\t\t\t\t}").AppendLine(); + } + else if (parameter.RefKind == RefKind.Ref) + { + sb.Append("\t\t\t\tif (").Append(wpc).Append(".Parameter").Append(parameterIndex).Append(" is global::Mockolate.Parameters.IRefParameter<").Append(parameter.Type.ToTypeOrWrapper()).Append("> ").Append(refParamBase).Append(parameterIndex).Append(")").AppendLine(); + sb.Append("\t\t\t\t{").AppendLine(); + sb.Append("\t\t\t\t\t").Append(parameter.Name).Append(" = ").Append(refParamBase).Append(parameterIndex).Append(".GetValue(").Append(parameter.Name).Append(");").AppendLine(); + sb.Append("\t\t\t\t}").AppendLine(); + } + } + + sb.Append("\t\t\t}").AppendLine(); + } + + sb.Append("\t\t\tif (").Append(mockRegistryName).Append(".Behavior.SkipInteractionRecording == false)").AppendLine(); + sb.Append("\t\t\t{").AppendLine(); + sb.Append("\t\t\t\t").Append(mockRegistryName).Append(".RegisterInteraction(new global::Mockolate.Interactions.MethodInvocation"); + if (delegateMethod.Parameters.Count > 0) + { + sb.Append('<').Append(string.Join(", ", delegateMethod.Parameters.Select(p => p.ToTypeOrWrapper()))).Append('>'); + } + + sb.Append("(").Append(delegateMethod.GetUniqueNameString()); + if (delegateMethod.Parameters.Count > 0) + { + sb.Append(", ").Append(string.Join(", ", delegateMethod.Parameters.Select(p => p.ToNameOrWrapper()))); + } + + sb.Append("));").AppendLine(); + sb.Append("\t\t\t}").AppendLine(); + + string displayDelegateName = + $"{delegateMethod.ContainingType}.{delegateMethod.Name}({string.Join(", ", delegateMethod.Parameters.Select(p => p.Type.DisplayName))})"; + sb.Append("\t\t\tif (").Append(methodSetup).Append(" is null && this.").Append(mockRegistryName).Append(".Behavior.ThrowWhenNotSetup)").AppendLine(); + sb.Append("\t\t\t{").AppendLine(); + sb.Append("\t\t\t\tthrow new global::Mockolate.Exceptions.MockNotSetupException(\"The method '").Append(displayDelegateName).Append("' was invoked without prior setup.\");").AppendLine(); + sb.Append("\t\t\t}").AppendLine(); + + AppendTriggerCallbacks(sb, "\t\t\t", methodSetup, delegateMethod.Parameters); + + if (delegateMethod.ReturnType != Type.Void) + { + string returnValue = Helpers.GetUniqueLocalVariableName("returnValue", delegateMethod.Parameters); + sb.Append("\t\t\treturn ").Append(methodSetup).Append("?.TryGetReturnValue("); + if (delegateMethod.Parameters.Count > 0) + { + sb.Append(string.Join(", ", delegateMethod.Parameters.Select(p => p.ToNameOrWrapper()))).Append(", "); + } + + sb.Append("out var ").Append(returnValue).Append(") == true ? ").Append(returnValue).Append(" : ") + .AppendDefaultValueGeneratorFor(delegateMethod.ReturnType, $"this.{mockRegistryName}.Behavior.DefaultValue") + .Append(';').AppendLine(); + } + + } } diff --git a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs index 711c58e4d..df61c9b62 100644 --- a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs +++ b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs @@ -505,6 +505,118 @@ public void MyTest() """ ); + [Fact] + public async Task WhenMockingInterfaceWithRefStructProperty_ShouldBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.IPacketHolder")}} + + namespace MyNamespace + { + public readonly ref struct Packet(int id) { public int Id { get; } = id; } + + public interface IPacketHolder + { + Packet Current { get; } + } + + public class MyClass + { + public void MyTest() + { + {|#0:IPacketHolder|}.CreateMock(); + } + } + } + """, + new DiagnosticResult("Mockolate0003", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("MyNamespace.IPacketHolder", "Current", + "properties of a non-span ref struct type are not supported") + ); + + [Fact] + public async Task WhenMockingInterfaceWithSpanProperty_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.ISpanBuffer")}} + + namespace MyNamespace + { + public interface ISpanBuffer + { + System.Span Buffer { get; set; } + } + + public class MyClass + { + public void MyTest() + { + ISpanBuffer.CreateMock(); + } + } + } + """ + ); + + [Fact] + public async Task WhenMockingInterfaceWithRefStructValuedIndexer_ShouldBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.IPacketCatalog")}} + + namespace MyNamespace + { + public readonly ref struct Packet(int id) { public int Id { get; } = id; } + + public interface IPacketCatalog + { + Packet this[int index] { get; } + } + + public class MyClass + { + public void MyTest() + { + {|#0:IPacketCatalog|}.CreateMock(); + } + } + } + """, + new DiagnosticResult("Mockolate0003", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("MyNamespace.IPacketCatalog", "this[]", + "indexers returning a non-span ref struct are not supported") + ); + + [Fact] + public async Task WhenMockingInterfaceWithRefStructEventArgument_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.IPacketNotifier")}} + + namespace MyNamespace + { + public readonly ref struct Packet(int id) { public int Id { get; } = id; } + + public delegate void PacketEventHandler(Packet packet); + + public interface IPacketNotifier + { + event PacketEventHandler PacketReceived; + } + + public class MyClass + { + public void MyTest() + { + IPacketNotifier.CreateMock(); + } + } + } + """ + ); + private static string GeneratedPrefix(string fullyQualifiedTypeName) { string simpleName = fullyQualifiedTypeName.Split('.')[^1]; diff --git a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs new file mode 100644 index 000000000..dce9bb9f5 --- /dev/null +++ b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs @@ -0,0 +1,173 @@ +#if NET9_0_OR_GREATER +using System; + +namespace Mockolate.Tests.RefStruct; + +/// +/// Covers ref structs in value positions - event arguments, method and delegate returns, +/// property types and indexer values. These once made the generator emit uncompilable code +/// (CS9244); they now either flow through the Span wrapper or degrade to a +/// stub with no setup surface. +/// +public sealed class RefStructValuePositionTests +{ + public delegate void PacketEventHandler(Packet packet); + + public delegate Packet PacketFactory(); + + public delegate void PacketConsumer(Packet packet); + + public interface IPacketNotifier + { + event PacketEventHandler PacketReceived; + } + + public interface ISpanEventSource + { + event EventHandler Received; + } + + public interface ISpanBuffer + { + Span Buffer { get; set; } + } + + public interface IPacketHolder + { + Packet Current { get; } + } + + public interface IPacketProducer + { + Packet Produce(); + } + + public interface IPacketCatalog + { + Packet this[int index] { get; } + } + + public sealed class EventTests + { + [Fact] + public async Task RaiseWithRefStructArgument_ShouldReachSubscriber() + { + IPacketNotifier sut = IPacketNotifier.CreateMock(); + int receivedId = 0; + sut.PacketReceived += packet => receivedId = packet.Id; + + sut.Mock.Raise.PacketReceived(new Packet(42, [])); + + await That(receivedId).IsEqualTo(42) + .Because("the strongly-typed Raise overload passes the ref struct straight to the delegate"); + } + + [Fact] + public async Task RaiseWithEventHandlerOfRefStruct_ShouldReachSubscriber() + { + ISpanEventSource sut = ISpanEventSource.CreateMock(); + int receivedId = 0; + sut.Received += (_, packet) => receivedId = packet.Id; + + sut.Mock.Raise.Received(sut, new Packet(7, [])); + + await That(receivedId).IsEqualTo(7) + .Because("EventHandler with a ref-struct T is raised like any other event"); + } + + [Fact] + public async Task RaiseWithoutSubscriber_ShouldNotThrow() + { + IPacketNotifier sut = IPacketNotifier.CreateMock(); + + void Act() => sut.Mock.Raise.PacketReceived(new Packet(1, [])); + + await That(Act).DoesNotThrow(); + } + } + + public sealed class SpanPropertyTests + { + [Fact] + public async Task Getter_ShouldReturnConfiguredSpan() + { + ISpanBuffer sut = ISpanBuffer.CreateMock(); + byte[] expected = [1, 2, 3,]; + sut.Mock.Setup.Buffer.InitializeWith(expected.AsSpan()); + + byte[] result = sut.Buffer.ToArray(); + + await That(result).IsEqualTo(expected) + .Because("a Span-typed property round-trips through SpanWrapper"); + } + + [Fact] + public async Task Setter_ShouldBeRecorded() + { + ISpanBuffer sut = ISpanBuffer.CreateMock(); + + sut.Buffer = new byte[] { 9, 8, }.AsSpan(); + + await That(sut.Mock.Verify.Buffer.Set(It.IsAny>())).Once(); + } + } + + public sealed class UnsupportedValuePositionTests + { + [Fact] + public async Task RefStructProperty_ShouldThrowNotSupported() + { + IPacketHolder sut = IPacketHolder.CreateMock(); + + void Act() => _ = sut.Current; + + await That(Act).Throws() + .WithMessage("*properties of a non-span ref struct type are not supported*").AsWildcard(); + } + + [Fact] + public async Task RefStructReturningMethod_ShouldThrowNotSupported() + { + IPacketProducer sut = IPacketProducer.CreateMock(); + + void Act() => _ = sut.Produce(); + + await That(Act).Throws() + .WithMessage("*methods returning a non-span ref struct are not supported*").AsWildcard(); + } + + [Fact] + public async Task RefStructIndexer_ShouldThrowNotSupported() + { + IPacketCatalog sut = IPacketCatalog.CreateMock(); + + void Act() => _ = sut[0]; + + await That(Act).Throws() + .WithMessage("*indexers returning a non-span ref struct are not supported*").AsWildcard(); + } + + [Fact] + public async Task RefStructReturningDelegate_ShouldThrowNotSupported() + { + PacketFactory sut = PacketFactory.CreateMock(); + + void Act() => _ = sut(); + + await That(Act).Throws() + .WithMessage("*methods returning a non-span ref struct are not supported*").AsWildcard(); + } + + [Fact] + public async Task RefStructParameterDelegate_ShouldThrowNotSupported() + { + PacketConsumer sut = PacketConsumer.CreateMock(); + + void Act() => sut(new Packet(1, [])); + + await That(Act).Throws() + .WithMessage("*ref-struct parameters are not supported on delegate types*").AsWildcard(); + } + } +} +#endif From 0bf2accf1548e0bd5aba1732880ee77c42eb34f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 15 Sep 2026 10:51:17 +0200 Subject: [PATCH 2/8] Move test helpers to dedicated namespace --- .../RefStruct/GeneratedPacketSinkTests.cs | 135 +----------------- .../RefStruct/ProjectionIndexerTests.cs | 1 + .../RefStructOutRefParameterTests.cs | 3 +- .../RefStruct/RefStructPrimitivesTests.cs | 1 + .../RefStruct/RefStructValuePositionTests.cs | 1 + .../RefStruct/IBigPacketLookup5.cs | 12 ++ .../TestHelpers/RefStruct/IBigPacketParser.cs | 11 ++ .../RefStruct/IBigPacketSetter5.cs | 12 ++ .../TestHelpers/RefStruct/IBigPacketSink.cs | 11 ++ .../TestHelpers/RefStruct/IBigPacketStore5.cs | 11 ++ .../RefStruct/IGeneratedDoublePacketStore.cs | 10 ++ .../RefStruct/IGeneratedMixedStore.cs | 10 ++ .../RefStruct/IGeneratedPacketLookup.cs | 11 ++ .../RefStruct/IGeneratedPacketParser.cs | 10 ++ .../RefStruct/IGeneratedPacketSetter.cs | 10 ++ .../RefStruct/IGeneratedPacketSink.cs | 10 ++ .../RefStruct/IGeneratedPacketStore.cs | 10 ++ .../RefStruct/IGeneratedPacketWriter.cs | 10 ++ .../TestHelpers/RefStruct/Packet.cs | 10 ++ 19 files changed, 154 insertions(+), 135 deletions(-) create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketLookup5.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketParser.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSetter5.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSink.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketStore5.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedDoublePacketStore.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedMixedStore.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketLookup.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketParser.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSetter.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSink.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketStore.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketWriter.cs create mode 100644 Tests/Mockolate.Tests/TestHelpers/RefStruct/Packet.cs diff --git a/Tests/Mockolate.Tests/RefStruct/GeneratedPacketSinkTests.cs b/Tests/Mockolate.Tests/RefStruct/GeneratedPacketSinkTests.cs index 6ad28e121..382ab008c 100644 --- a/Tests/Mockolate.Tests/RefStruct/GeneratedPacketSinkTests.cs +++ b/Tests/Mockolate.Tests/RefStruct/GeneratedPacketSinkTests.cs @@ -2,143 +2,10 @@ using System.Collections.Generic; using System.Linq; using Mockolate.Exceptions; +using Mockolate.Tests.TestHelpers.RefStruct; namespace Mockolate.Tests.RefStruct; -/// -/// A ref struct used for end-to-end generator tests. -/// -public readonly ref struct Packet(int id, ReadOnlySpan payload) -{ - public int Id { get; } = id; - public ReadOnlySpan Payload { get; } = payload; -} - -/// -/// Generator-target interface: single void method with a single ref-struct parameter. This -/// is the smallest non-trivial shape that exercises the ref-struct setup pipeline. -/// -public interface IGeneratedPacketSink -{ - void Consume(Packet packet); -} - -/// -/// Generator-target: ref-struct parameter plus a non-ref-struct parameter in the same -/// signature. Proves mixed parameters route through RefStructVoidMethodSetup<T1, T2>. -/// -public interface IGeneratedPacketWriter -{ - void Write(Packet packet, int priority); -} - -/// -/// Generator-target: non-void return with a ref-struct parameter. Uses -/// RefStructReturnMethodSetup<int, Packet>. -/// -public interface IGeneratedPacketParser -{ - int TryParse(Packet packet); -} - -/// -/// Generator-target: indexer keyed by a ref struct. Not wired up in commit E — both -/// accessors throw NotSupportedException. The analyzer will flag this pattern at -/// compile time in commit F. -/// -public interface IGeneratedPacketLookup -{ - string this[Packet key] { get; } -} - -/// -/// Generator-target: ref-struct-keyed indexer with both a getter and a setter. Exercises the -/// combined IRefStructIndexerSetup<TValue, T> facade. -/// -public interface IGeneratedPacketStore -{ - string this[Packet key] { get; set; } -} - -/// -/// Generator-target: ref-struct-keyed indexer with only a setter. Exercises the -/// setter-only IRefStructIndexerSetterSetup<TValue, T> facade. -/// -public interface IGeneratedPacketSetter -{ - string this[Packet key] { set; } -} - -/// -/// Generator-target: two-key indexer mixing a non-ref-struct parameter and a ref-struct key. -/// Exercises arity-2 projection storage with one boxed raw key and one projected key. -/// -public interface IGeneratedMixedStore -{ - string this[int priority, Packet key] { get; set; } -} - -/// -/// Generator-target: two-key indexer with two ref-struct keys. Exercises arity-2 projection -/// storage where every slot carries a projection. -/// -public interface IGeneratedDoublePacketStore -{ - string this[Packet k1, Packet k2] { get; set; } -} - -/// -/// Generator-target: arity-5 indexer mixing three ref-struct keys and two non-ref-struct -/// slots. Exercises the generator-emitted RefStructIndexerGetterSetup / -/// RefStructIndexerSetterSetup / RefStructIndexerSetup classes at arity 5+. -/// -public interface IBigPacketStore5 -{ - string this[Packet k1, int a, Packet k2, string b, Packet k3] { get; set; } -} - -/// -/// Generator-target: an arity-5 ref-struct-parameter void method. The runtime types for -/// arity 1-4 are hand-written; arity 5+ are generator-emitted into -/// RefStructMethodSetups.g.cs. -/// -public interface IBigPacketSink -{ - void Absorb(Packet p1, Packet p2, Packet p3, Packet p4, Packet p5); -} - -/// -/// Generator-target: an arity-6 ref-struct-parameter return method that mixes Packet with -/// non-ref-struct types (int, string), verifying allows ref struct is satisfied by any -/// type and that return-side wiring works at extended arity. -/// -public interface IBigPacketParser -{ - int TryParse(Packet p1, Packet p2, int offset, Packet p4, Packet p5, string format); -} - -/// -/// Generator-target: arity-5 setter-only indexer mixing three ref-struct keys and two -/// non-ref-struct slots. Exercises the generator-emitted setter-only -/// RefStructIndexerSetterSetup at arity 5+ — the get+set fixture () -/// does not cover the setter-only emit branch because it always wires a getter. -/// -public interface IBigPacketSetter5 -{ - string this[Packet k1, int a, Packet k2, string b, Packet k3] { set; } -} - -/// -/// Generator-target: arity-5 getter-only indexer mixing three ref-struct keys and two -/// non-ref-struct slots. Exercises the generator-emitted getter-only -/// RefStructIndexerGetterSetup at arity 5+ — the get+set fixture () -/// wires a getter through the combined facade rather than the standalone getter-only path. -/// -public interface IBigPacketLookup5 -{ - string this[Packet k1, int a, Packet k2, string b, Packet k3] { get; } -} - public sealed class GeneratedPacketSinkTests { public sealed class VoidArity1Tests diff --git a/Tests/Mockolate.Tests/RefStruct/ProjectionIndexerTests.cs b/Tests/Mockolate.Tests/RefStruct/ProjectionIndexerTests.cs index ae70c9a8b..7fa7a9216 100644 --- a/Tests/Mockolate.Tests/RefStruct/ProjectionIndexerTests.cs +++ b/Tests/Mockolate.Tests/RefStruct/ProjectionIndexerTests.cs @@ -1,6 +1,7 @@ #if NET9_0_OR_GREATER using Mockolate.Parameters; using Mockolate.Setup; +using Mockolate.Tests.TestHelpers.RefStruct; namespace Mockolate.Tests.RefStruct; diff --git a/Tests/Mockolate.Tests/RefStruct/RefStructOutRefParameterTests.cs b/Tests/Mockolate.Tests/RefStruct/RefStructOutRefParameterTests.cs index 23cb3a14a..80cf4d38a 100644 --- a/Tests/Mockolate.Tests/RefStruct/RefStructOutRefParameterTests.cs +++ b/Tests/Mockolate.Tests/RefStruct/RefStructOutRefParameterTests.cs @@ -2,6 +2,7 @@ using GcPacket = Mockolate.Tests.GeneratorCoverage.Packet; using Mockolate.Setup; using Mockolate.Tests.GeneratorCoverage; +using Mockolate.Tests.TestHelpers.RefStruct; namespace Mockolate.Tests.RefStruct; @@ -10,7 +11,7 @@ namespace Mockolate.Tests.RefStruct; /// through / /// . Uses the /// ref struct (single-int ctor) to isolate this -/// scenario from the payload-carrying used by other RefStruct tests. +/// scenario from the payload-carrying used by other RefStruct tests. /// /// /// Ref struct values cannot survive an await boundary, so each test captures diff --git a/Tests/Mockolate.Tests/RefStruct/RefStructPrimitivesTests.cs b/Tests/Mockolate.Tests/RefStruct/RefStructPrimitivesTests.cs index c8e0914a1..9ff621ecb 100644 --- a/Tests/Mockolate.Tests/RefStruct/RefStructPrimitivesTests.cs +++ b/Tests/Mockolate.Tests/RefStruct/RefStructPrimitivesTests.cs @@ -3,6 +3,7 @@ using Mockolate.Interactions; using Mockolate.Parameters; using Mockolate.Setup; +using Mockolate.Tests.TestHelpers.RefStruct; namespace Mockolate.Tests.RefStruct; diff --git a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs index dce9bb9f5..1c78022ad 100644 --- a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs +++ b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs @@ -1,5 +1,6 @@ #if NET9_0_OR_GREATER using System; +using Mockolate.Tests.TestHelpers.RefStruct; namespace Mockolate.Tests.RefStruct; diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketLookup5.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketLookup5.cs new file mode 100644 index 000000000..b95379200 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketLookup5.cs @@ -0,0 +1,12 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: arity-5 getter-only indexer mixing three ref-struct keys and two +/// non-ref-struct slots. Exercises the generator-emitted getter-only +/// RefStructIndexerGetterSetup at arity 5+ — the get+set fixture () +/// wires a getter through the combined facade rather than the standalone getter-only path. +/// +public interface IBigPacketLookup5 +{ + string this[Packet k1, int a, Packet k2, string b, Packet k3] { get; } +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketParser.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketParser.cs new file mode 100644 index 000000000..ff9e5b948 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketParser.cs @@ -0,0 +1,11 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: an arity-6 ref-struct-parameter return method that mixes Packet with +/// non-ref-struct types (int, string), verifying allows ref struct is satisfied by any +/// type and that return-side wiring works at extended arity. +/// +public interface IBigPacketParser +{ + int TryParse(Packet p1, Packet p2, int offset, Packet p4, Packet p5, string format); +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSetter5.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSetter5.cs new file mode 100644 index 000000000..9a718cfea --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSetter5.cs @@ -0,0 +1,12 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: arity-5 setter-only indexer mixing three ref-struct keys and two +/// non-ref-struct slots. Exercises the generator-emitted setter-only +/// RefStructIndexerSetterSetup at arity 5+ — the get+set fixture () +/// does not cover the setter-only emit branch because it always wires a getter. +/// +public interface IBigPacketSetter5 +{ + string this[Packet k1, int a, Packet k2, string b, Packet k3] { set; } +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSink.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSink.cs new file mode 100644 index 000000000..089e8e0a8 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSink.cs @@ -0,0 +1,11 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: an arity-5 ref-struct-parameter void method. The runtime types for +/// arity 1-4 are hand-written; arity 5+ are generator-emitted into +/// RefStructMethodSetups.g.cs. +/// +public interface IBigPacketSink +{ + void Absorb(Packet p1, Packet p2, Packet p3, Packet p4, Packet p5); +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketStore5.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketStore5.cs new file mode 100644 index 000000000..2c0584a3f --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketStore5.cs @@ -0,0 +1,11 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: arity-5 indexer mixing three ref-struct keys and two non-ref-struct +/// slots. Exercises the generator-emitted RefStructIndexerGetterSetup / +/// RefStructIndexerSetterSetup / RefStructIndexerSetup classes at arity 5+. +/// +public interface IBigPacketStore5 +{ + string this[Packet k1, int a, Packet k2, string b, Packet k3] { get; set; } +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedDoublePacketStore.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedDoublePacketStore.cs new file mode 100644 index 000000000..1c5ce7ef9 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedDoublePacketStore.cs @@ -0,0 +1,10 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: two-key indexer with two ref-struct keys. Exercises arity-2 projection +/// storage where every slot carries a projection. +/// +public interface IGeneratedDoublePacketStore +{ + string this[Packet k1, Packet k2] { get; set; } +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedMixedStore.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedMixedStore.cs new file mode 100644 index 000000000..04ad6a9e5 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedMixedStore.cs @@ -0,0 +1,10 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: two-key indexer mixing a non-ref-struct parameter and a ref-struct key. +/// Exercises arity-2 projection storage with one boxed raw key and one projected key. +/// +public interface IGeneratedMixedStore +{ + string this[int priority, Packet key] { get; set; } +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketLookup.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketLookup.cs new file mode 100644 index 000000000..66f458944 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketLookup.cs @@ -0,0 +1,11 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: indexer keyed by a ref struct. Not wired up in commit E — both +/// accessors throw NotSupportedException. The analyzer will flag this pattern at +/// compile time in commit F. +/// +public interface IGeneratedPacketLookup +{ + string this[Packet key] { get; } +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketParser.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketParser.cs new file mode 100644 index 000000000..bb2b405b9 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketParser.cs @@ -0,0 +1,10 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: non-void return with a ref-struct parameter. Uses +/// RefStructReturnMethodSetup<int, Packet>. +/// +public interface IGeneratedPacketParser +{ + int TryParse(Packet packet); +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSetter.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSetter.cs new file mode 100644 index 000000000..09ef6b166 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSetter.cs @@ -0,0 +1,10 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: ref-struct-keyed indexer with only a setter. Exercises the +/// setter-only IRefStructIndexerSetterSetup<TValue, T> facade. +/// +public interface IGeneratedPacketSetter +{ + string this[Packet key] { set; } +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSink.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSink.cs new file mode 100644 index 000000000..88aac0773 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSink.cs @@ -0,0 +1,10 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target interface: single void method with a single ref-struct parameter. This +/// is the smallest non-trivial shape that exercises the ref-struct setup pipeline. +/// +public interface IGeneratedPacketSink +{ + void Consume(Packet packet); +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketStore.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketStore.cs new file mode 100644 index 000000000..e20790215 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketStore.cs @@ -0,0 +1,10 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: ref-struct-keyed indexer with both a getter and a setter. Exercises the +/// combined IRefStructIndexerSetup<TValue, T> facade. +/// +public interface IGeneratedPacketStore +{ + string this[Packet key] { get; set; } +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketWriter.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketWriter.cs new file mode 100644 index 000000000..ab5c1ab65 --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketWriter.cs @@ -0,0 +1,10 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// Generator-target: ref-struct parameter plus a non-ref-struct parameter in the same +/// signature. Proves mixed parameters route through RefStructVoidMethodSetup<T1, T2>. +/// +public interface IGeneratedPacketWriter +{ + void Write(Packet packet, int priority); +} \ No newline at end of file diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/Packet.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/Packet.cs new file mode 100644 index 000000000..ce90bc20e --- /dev/null +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/Packet.cs @@ -0,0 +1,10 @@ +namespace Mockolate.Tests.TestHelpers.RefStruct; + +/// +/// A ref struct used for end-to-end generator tests. +/// +public readonly ref struct Packet(int id, ReadOnlySpan payload) +{ + public int Id { get; } = id; + public ReadOnlySpan Payload { get; } = payload; +} \ No newline at end of file From 950e26829dee97325caeac75662fd375ef0a1567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 15 Sep 2026 11:04:14 +0200 Subject: [PATCH 3/8] fix: degrade unsupported ref-struct members on every target 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. --- Docs/pages/08-analyzers.md | 35 ++++-- .../MockabilityAnalyzer.cs | 17 +-- Source/Mockolate.Analyzers/Resources.resx | 6 +- Source/Mockolate.SourceGenerators/Helpers.cs | 38 +++--- .../Sources/Sources.MockClass.cs | 114 ++++++++---------- .../Sources/Sources.MockDelegate.cs | 11 +- .../RefStruct/RefStructValuePositionTests.cs | 63 +++++----- 7 files changed, 148 insertions(+), 136 deletions(-) diff --git a/Docs/pages/08-analyzers.md b/Docs/pages/08-analyzers.md index f87597769..718872d8d 100644 --- a/Docs/pages/08-analyzers.md +++ b/Docs/pages/08-analyzers.md @@ -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** @@ -91,17 +91,34 @@ framework and/or `` 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` / - non-`ReadOnlySpan` ref struct - the mock can't round-trip the value through - `IOutParameter` / `IRefParameter` when `T` is a ref struct. -- Methods returning a non-`Span` / non-`ReadOnlySpan` ref struct. +A non-`Span` / non-`ReadOnlySpan` 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` (`IReturnMethodSetup`, +`IPropertyGetterOnlySetup`, `IIndexerGetterOnlySetup`), 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` / `ReturnMethodSetup`, neither of which carries the anti-constraint the + interface and class pipelines get from `RefStructVoidMethodSetup`. + +Members matching any of these still compile: the generated mock keeps the member but its body throws +`NotSupportedException`, and no setup or verify surface is emitted for it. Everything else on the +type stays mockable. **Note:** `Span` and `ReadOnlySpan` 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. 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. diff --git a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs index 63b954716..bc48c9280 100644 --- a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs +++ b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs @@ -302,6 +302,16 @@ 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)) + { + issue = "methods returning a non-span ref struct are not supported"; + return true; + } + bool hasRefStructParam = false; foreach (IParameterSymbol p in method.Parameters) { @@ -334,13 +344,6 @@ 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; } diff --git a/Source/Mockolate.Analyzers/Resources.resx b/Source/Mockolate.Analyzers/Resources.resx index 834112e71..cbf7ee935 100644 --- a/Source/Mockolate.Analyzers/Resources.resx +++ b/Source/Mockolate.Analyzers/Resources.resx @@ -146,12 +146,12 @@ Mock arguments must be mockable - 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. + 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. - Type '{0}' has a method '{1}' that cannot be mocked: {2} + Type '{0}' has a member '{1}' that cannot be mocked: {2} - Ref-struct parameter mocking is not supported on this compilation + Ref-struct usage is not supported on this compilation diff --git a/Source/Mockolate.SourceGenerators/Helpers.cs b/Source/Mockolate.SourceGenerators/Helpers.cs index 98fc2c79e..68f764a0a 100644 --- a/Source/Mockolate.SourceGenerators/Helpers.cs +++ b/Source/Mockolate.SourceGenerators/Helpers.cs @@ -219,32 +219,28 @@ public static string ToTypeOrWrapper(this Type type) /// type AND the existing Span/ReadOnlySpan wrapper fallback doesn't apply. /// /// - /// System.Span<T> and System.ReadOnlySpan<T> are themselves - /// ref-like, but the generator already boxes them into SpanWrapper<T> / - /// ReadOnlySpanWrapper<T> (a plain class), so their setup flows through the - /// regular VoidMethodSetup hierarchy with a non-ref-struct T. Only types - /// outside that wrapping need the RefStructVoidMethodSetup / - /// RefStructReturnMethodSetup / RefStructIndexerGetterSetup path. + /// + /// System.Span<T> and System.ReadOnlySpan<T> are themselves + /// ref-like, but the generator already boxes them into SpanWrapper<T> / + /// ReadOnlySpanWrapper<T> (a plain class), so their setup flows through the + /// regular VoidMethodSetup hierarchy with a non-ref-struct T. Only types + /// outside that wrapping need the RefStructVoidMethodSetup / + /// RefStructReturnMethodSetup / RefStructIndexerGetterSetup path. + /// + /// + /// In a value 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 Func<T> (IReturnMethodSetup<T>, + /// IPropertyGetterOnlySetup<T>, IIndexerGetterOnlySetup<TValue, ...>), + /// which is illegal for a ref struct, so they cannot carry the allows ref struct + /// anti-constraint the parameter positions use. Members matching this in a value position get + /// a NotSupportedException stub and no setup/verify surface. + /// /// public static bool NeedsRefStructPipeline(this Type type) => type.IsRefStruct && type.SpecialGenericType is not (SpecialGenericType.Span or SpecialGenericType.ReadOnlySpan); - /// - /// Returns true if the type cannot occupy a value position - a method or delegate return, - /// a property type, or an indexer value - anywhere in the setup pipeline. - /// - /// - /// Value positions parameterize types that store a Func<T> (IReturnMethodSetup<T>, - /// IPropertyGetterOnlySetup<T>, IIndexerGetterOnlySetup<TValue, ...>), which is - /// illegal for a ref struct, so they cannot carry the allows ref struct anti-constraint the - /// parameter positions use. Span<T>/ReadOnlySpan<T> are exempt: they reach those - /// types as SpanWrapper<T>/ReadOnlySpanWrapper<T>. Members matching this get a - /// NotSupportedException stub and no setup/verify surface. - /// - public static bool IsUnsupportedRefStructValue(this Type type) - => type.NeedsRefStructPipeline(); - /// /// Returns true if the parameter must flow through the ref-struct setup pipeline. The /// overload excludes Span<T> and ReadOnlySpan<T> diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs index e7a5c83ee..71507d433 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs @@ -1764,8 +1764,8 @@ property.IndexerParameters is not null property.IndexerParameters.Value.Any(p => p.NeedsRefStructPipeline()); // A non-span ref-struct property or indexer value has no setup surface either (see - // Helpers.IsUnsupportedRefStructValue), so both accessors throw for the same reason. - bool isUnsupportedRefStructValue = property.Type.IsUnsupportedRefStructValue(); + // Helpers.NeedsRefStructPipeline), so both accessors throw for the same reason. + bool isUnsupportedRefStructValue = property.Type.NeedsRefStructPipeline(); void AppendUnsupportedRefStructValueThrow() => sb.Append("\t\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: ") @@ -2333,29 +2333,29 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb, // shapes, the body throws NotSupportedException so the rest of the type still compiles. if (method.HasUnsupportedAllowsRefStructTypeParameter) { - sb.Append( - "\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: methods with a generic type parameter declaring 'allows ref struct' are not supported. Method '") - .Append(method.ContainingType).Append('.').Append(method.Name).Append("'.\");").AppendLine(); + AppendUnsupportedMethodThrow(sb, method, + "methods with a generic type parameter declaring 'allows ref struct' are not supported"); sb.AppendLine("\t\t}"); return; } - // Methods with at least one ref-struct parameter (outside the Span/ReadOnlySpan wrapper - // carve-out) route through the ref-struct setup pipeline. The ref-struct value cannot - // be captured in a closure, so we emit a synchronous, stack-bound match/invoke loop. - if (method.Parameters.Any(p => p.NeedsRefStructPipeline())) + // Checked ahead of the ref-struct-parameter branch below: a non-span ref-struct return has no + // setup surface on any target (see Helpers.NeedsRefStructPipeline), so routing such a method into + // the ref-struct pipeline would trade a working stub for that pipeline's pre-.NET 9 #error and + // break the whole compilation over a member that can never be supported anyway. + if (method.ReturnType.NeedsRefStructPipeline()) { - AppendMockSubject_ImplementClass_AddRefStructMethodBody(sb, method, mockRegistry); + AppendUnsupportedMethodThrow(sb, method, "methods returning a non-span ref struct are not supported"); sb.AppendLine("\t\t}"); return; } - // A non-span ref-struct return has no setup surface to route through (see - // Helpers.IsUnsupportedRefStructValue), so the body throws instead. The ref-struct-parameter - // branch above emits the same message from its own carve-out. - if (method.ReturnType.IsUnsupportedRefStructValue()) + // Methods with at least one ref-struct parameter (outside the Span/ReadOnlySpan wrapper + // carve-out) route through the ref-struct setup pipeline. The ref-struct value cannot + // be captured in a closure, so we emit a synchronous, stack-bound match/invoke loop. + if (method.Parameters.Any(p => p.NeedsRefStructPipeline())) { - AppendUnsupportedRefStructReturnThrow(sb, method); + AppendMockSubject_ImplementClass_AddRefStructMethodBody(sb, method, mockRegistry); sb.AppendLine("\t\t}"); return; } @@ -2746,9 +2746,10 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb, /// /// Out of scope (throws NotSupportedException at mock-invocation time, but the /// mock class still compiles so the rest of the interface can be mocked): - /// arity above 4; out/ref ref-struct parameters; ref-struct return types that aren't - /// Span<T>/ReadOnlySpan<T>. Proper analyzer-level diagnostics - /// for these live in MockabilityAnalyzer. + /// arity above 4; out/ref ref-struct parameters. A non-span ref-struct return never reaches + /// here - the caller stubs it out first, so it degrades on every target instead of hitting + /// the #error above. Proper analyzer-level diagnostics for these live in + /// MockabilityAnalyzer. /// /// private static void AppendMockSubject_ImplementClass_AddRefStructMethodBody( @@ -2756,22 +2757,6 @@ private static void AppendMockSubject_ImplementClass_AddRefStructMethodBody( { sb.Append("#if NET9_0_OR_GREATER").AppendLine(); - bool returnsUnsupportedRefStruct = method.ReturnType.IsRefStruct && - method.ReturnType.SpecialGenericType is not - (SpecialGenericType.Span or SpecialGenericType.ReadOnlySpan); - - if (returnsUnsupportedRefStruct) - { - AppendUnsupportedRefStructReturnThrow(sb, method); - sb.Append("#else").AppendLine(); - sb.Append( - "#error Mockolate: methods with ref-struct parameters require .NET 9 or later (uses the 'allows ref struct' anti-constraint).") - .AppendLine(); - sb.Append("\t\t\tthrow new global::System.NotSupportedException();").AppendLine(); - sb.Append("#endif").AppendLine(); - return; - } - string typeParams = string.Join(", ", method.Parameters.Select(p => p.Type.Fullname)); string setupType = method.ReturnType == Type.Void ? $"global::Mockolate.Setup.RefStructVoidMethodSetup<{typeParams}>" @@ -3064,6 +3049,16 @@ private static void AppendRefStructIndexerSetterBody(StringBuilder sb, Property sb.Append("#endif").AppendLine(); } + /// + /// Emits the NotSupportedException body for a method-shaped member that has no setup + /// surface, so every carve-out - including the delegate ones in Sources.MockDelegate - + /// reports in the same shape the analyzer uses. + /// + private static void AppendUnsupportedMethodThrow(StringBuilder sb, Method method, string reason) + => sb.Append("\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: ").Append(reason) + .Append(". Method '").Append(method.ContainingType).Append('.').Append(method.Name) + .Append("'.\");").AppendLine(); + #endregion Mock Helpers #region Setup Helpers @@ -3295,7 +3290,7 @@ private static void DefineSetupInterface(StringBuilder sb, Class @class, MemberT Func propertyPredicate = property => property.ExplicitImplementation is null && property is { IsIndexer: false, } && - property.MemberType == memberType && !property.Type.IsUnsupportedRefStructValue(); + property.MemberType == memberType && !property.Type.NeedsRefStructPipeline(); foreach (Property property in @class.AllProperties().Where(propertyPredicate)) { sb.AppendXmlSummary( @@ -3325,7 +3320,7 @@ private static void DefineSetupInterface(StringBuilder sb, Class @class, MemberT Func indexerPredicate = indexer => indexer.ExplicitImplementation is null && indexer is { IsIndexer: true, } && - indexer.MemberType == memberType && !indexer.Type.IsUnsupportedRefStructValue(); + indexer.MemberType == memberType && !indexer.Type.NeedsRefStructPipeline(); foreach (Property indexer in @class.AllProperties().Where(indexerPredicate)) { AppendIndexerSetupDefinition(sb, indexer, hasOverloadResolutionPriority: hasOverloadResolutionPriority); @@ -3508,14 +3503,13 @@ private static void AppendMethodSetupDefinition(StringBuilder sb, Class @class, bool useParameters, string? methodNameOverride = null, bool[]? valueFlags = null, bool hasOverloadResolutionPriority = false, bool perElementParams = false) { - // Methods using a generic type parameter that declares `allows ref struct` cannot expose - // a setup surface: IReturnMethodSetup / IVoidMethodSetup do not carry the same - // anti-constraint. The override body throws NotSupportedException, so no setup is needed. - // A non-span ref-struct return is unsupported for the same reason, in both the regular and - // the ref-struct-parameter pipeline, so it is checked ahead of the branch below - as is a - // ref-struct parameter on a delegate, which has no ref-struct pipeline to route into. + // Three shapes cannot expose a setup surface, all for the same reason: IReturnMethodSetup / + // IVoidMethodSetup do not carry the `allows ref struct` anti-constraint. A generic type + // parameter that declares it, a non-span ref-struct return, and a ref-struct parameter on a + // delegate (which has no ref-struct pipeline to route into) each make the override body a + // NotSupportedException stub, so no setup is needed. if (method.HasUnsupportedAllowsRefStructTypeParameter || - method.ReturnType.IsUnsupportedRefStructValue() || + method.ReturnType.NeedsRefStructPipeline() || method.HasUnsupportedRefStructParameter) { return; @@ -3735,7 +3729,7 @@ private static void ImplementSetupInterface(StringBuilder sb, Class @class, stri Func propertyPredicate = property => property.ExplicitImplementation is null && property is { IsIndexer: false, } && - property.MemberType == memberType && !property.Type.IsUnsupportedRefStructValue(); + property.MemberType == memberType && !property.Type.NeedsRefStructPipeline(); foreach (Property property in @class.AllProperties().Where(propertyPredicate)) { sb.Append("\t\t/// ").AppendLine(); @@ -3794,7 +3788,7 @@ private static void ImplementSetupInterface(StringBuilder sb, Class @class, stri Func indexerPredicate = indexer => indexer.ExplicitImplementation is null && indexer is { IsIndexer: true, } && - indexer.MemberType == memberType && !indexer.Type.IsUnsupportedRefStructValue(); + indexer.MemberType == memberType && !indexer.Type.NeedsRefStructPipeline(); foreach (Property indexer in @class.AllProperties().Where(indexerPredicate)) { AppendIndexerSetupImplementation(sb, indexer, mockRegistryName, setupName, memberIds, memberIdPrefix, @@ -3895,7 +3889,7 @@ private static void AppendMethodSetupImplementation(StringBuilder sb, Method met // parameter have no setup interface declaration (see AppendMethodSetupDefinition), so no // explicit implementation is emitted either. if (method.HasUnsupportedAllowsRefStructTypeParameter || - method.ReturnType.IsUnsupportedRefStructValue() || + method.ReturnType.NeedsRefStructPipeline() || method.HasUnsupportedRefStructParameter) { return; @@ -5084,16 +5078,6 @@ private static void AppendIndexerVerifyParameterMatches(StringBuilder sb, #endregion Setup Helpers - /// - /// Emits the NotSupportedException body shared by every method whose return type is an - /// unsupported ref struct, so both carve-outs report the same message. - /// - private static void AppendUnsupportedRefStructReturnThrow(StringBuilder sb, Method method) - => sb.Append("\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: ") - .Append("methods returning a non-span ref struct are not supported") - .Append(". Method '").Append(method.ContainingType).Append('.') - .Append(method.Name).Append("'.\");").AppendLine(); - #region Raise Helpers /// @@ -5242,7 +5226,7 @@ private static void DefineVerifyInterface(StringBuilder sb, Class @class, string Func propertyPredicate = property => property.ExplicitImplementation is null && property is { IsIndexer: false, } && - property.MemberType == memberType && !property.Type.IsUnsupportedRefStructValue(); + property.MemberType == memberType && !property.Type.NeedsRefStructPipeline(); foreach (Property property in @class.AllProperties().Where(propertyPredicate)) { sb.AppendXmlSummary( @@ -5258,7 +5242,7 @@ private static void DefineVerifyInterface(StringBuilder sb, Class @class, string Func indexerPredicate = indexer => indexer.ExplicitImplementation is null && indexer is { IsIndexer: true, } && - indexer.MemberType == memberType && !indexer.Type.IsUnsupportedRefStructValue(); + indexer.MemberType == memberType && !indexer.Type.NeedsRefStructPipeline(); foreach (Property indexer in @class.AllProperties().Where(indexerPredicate)) { AppendIndexerVerifyDefinition(sb, indexer, verifyName, @@ -5371,7 +5355,11 @@ private static void AppendMethodVerifyDefinition(StringBuilder sb, Method method // closures over the captured call values — incompatible with a ref-struct T. Callers // fall back to reading `mock.Mock.Registry.Interactions.OfType()` // for count-based verification. See brief's "verify count" guidance. - if (method.Parameters.Any(p => p.NeedsRefStructPipeline())) + // + // A non-span ref-struct return is skipped for a different reason: the body is a + // NotSupportedException stub, so the call never reaches interaction recording and the + // surface could only ever report zero. Matches the property/indexer carve-out. + if (method.Parameters.Any(p => p.NeedsRefStructPipeline()) || method.ReturnType.NeedsRefStructPipeline()) { return; } @@ -5507,7 +5495,7 @@ private static void ImplementVerifyInterface(StringBuilder sb, Class @class, str Func propertyPredicate = property => property.ExplicitImplementation is null && property is { IsIndexer: false, } && - property.MemberType == memberType && !property.Type.IsUnsupportedRefStructValue(); + property.MemberType == memberType && !property.Type.NeedsRefStructPipeline(); foreach (Property property in @class.AllProperties().Where(propertyPredicate)) { bool useFastForProperty = useFastBuffers && IsFastBufferEligibleProperty(property); @@ -5548,7 +5536,7 @@ private static void ImplementVerifyInterface(StringBuilder sb, Class @class, str Func indexerPredicate = indexer => indexer.ExplicitImplementation is null && indexer is { IsIndexer: true, IndexerParameters: not null, } && - indexer.MemberType == memberType && !indexer.Type.IsUnsupportedRefStructValue(); + indexer.MemberType == memberType && !indexer.Type.NeedsRefStructPipeline(); foreach (Property indexer in @class.AllProperties().Where(indexerPredicate)) { AppendIndexerVerifyImplementation(sb, indexer, mockRegistryName, verifyName, memberIds, memberIdPrefix, @@ -5679,7 +5667,7 @@ private static void AppendMethodVerifyImplementation(StringBuilder sb, Method me #pragma warning restore S107 { // Mirror the AppendMethodVerifyDefinition short-circuit for ref-struct signatures. - if (method.Parameters.Any(p => p.NeedsRefStructPipeline())) + if (method.Parameters.Any(p => p.NeedsRefStructPipeline()) || method.ReturnType.NeedsRefStructPipeline()) { return; } @@ -6030,7 +6018,7 @@ private static bool IsFastBufferEligibleIndexer(Property indexer) indexer.IndexerParameters is null || indexer.IndexerParameters.Value.Count == 0 || indexer.IndexerParameters.Value.Count > 4 || - indexer.Type.IsUnsupportedRefStructValue()) + indexer.Type.NeedsRefStructPipeline()) { return false; } diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs index 06bb74de9..2434a36cf 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs @@ -381,17 +381,17 @@ private static void AppendMockDelegateInvokeBody(StringBuilder sb, Method delega // A non-span ref-struct return cannot parameterize ReturnMethodSetup, and a ref-struct // parameter cannot parameterize VoidMethodSetup. Either way the delegate gets no setup or // verify surface (AppendMethodSetupDefinition skips it) and Invoke throws. - if (delegateMethod.ReturnType.IsUnsupportedRefStructValue()) + if (delegateMethod.ReturnType.NeedsRefStructPipeline()) { - AppendUnsupportedRefStructReturnThrow(sb, delegateMethod); + AppendUnsupportedMethodThrow(sb, delegateMethod, + "methods returning a non-span ref struct are not supported"); return; } if (delegateMethod.HasUnsupportedRefStructParameter) { - sb.Append("\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: ") - .Append("ref-struct parameters are not supported on delegate types") - .Append(". Delegate '").Append(delegateMethod.ContainingType).Append("'.\");").AppendLine(); + AppendUnsupportedMethodThrow(sb, delegateMethod, + "ref-struct parameters are not supported on delegate types"); return; } @@ -528,6 +528,5 @@ private static void AppendMockDelegateInvokeBody(StringBuilder sb, Method delega .AppendDefaultValueGeneratorFor(delegateMethod.ReturnType, $"this.{mockRegistryName}.Behavior.DefaultValue") .Append(';').AppendLine(); } - } } diff --git a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs index 1c78022ad..e1c761d6e 100644 --- a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs +++ b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs @@ -1,4 +1,3 @@ -#if NET9_0_OR_GREATER using System; using Mockolate.Tests.TestHelpers.RefStruct; @@ -10,27 +9,22 @@ namespace Mockolate.Tests.RefStruct; /// (CS9244); they now either flow through the Span wrapper or degrade to a /// stub with no setup surface. /// +/// +/// Deliberately not gated to net9.0+: none of these shapes route through the ref-struct setup +/// pipeline, so they must degrade identically on every supported target. Two nested fixtures are +/// gated on what the target offers, not on the mock: SpanWrapper<T> ships from +/// net8.0 upwards, and EventHandler<T> gained its allows ref struct +/// anti-constraint in .NET 9. +/// public sealed class RefStructValuePositionTests { - public delegate void PacketEventHandler(Packet packet); + public delegate void PacketHandler(Packet packet); public delegate Packet PacketFactory(); - public delegate void PacketConsumer(Packet packet); - public interface IPacketNotifier { - event PacketEventHandler PacketReceived; - } - - public interface ISpanEventSource - { - event EventHandler Received; - } - - public interface ISpanBuffer - { - Span Buffer { get; set; } + event PacketHandler PacketReceived; } public interface IPacketHolder @@ -63,6 +57,22 @@ await That(receivedId).IsEqualTo(42) .Because("the strongly-typed Raise overload passes the ref struct straight to the delegate"); } + [Fact] + public async Task RaiseWithoutSubscriber_ShouldNotThrow() + { + IPacketNotifier sut = IPacketNotifier.CreateMock(); + + void Act() => sut.Mock.Raise.PacketReceived(new Packet(1, [])); + + await That(Act).DoesNotThrow(); + } + +#if NET9_0_OR_GREATER + public interface ISpanEventSource + { + event EventHandler Received; + } + [Fact] public async Task RaiseWithEventHandlerOfRefStruct_ShouldReachSubscriber() { @@ -75,20 +85,17 @@ public async Task RaiseWithEventHandlerOfRefStruct_ShouldReachSubscriber() await That(receivedId).IsEqualTo(7) .Because("EventHandler with a ref-struct T is raised like any other event"); } - - [Fact] - public async Task RaiseWithoutSubscriber_ShouldNotThrow() - { - IPacketNotifier sut = IPacketNotifier.CreateMock(); - - void Act() => sut.Mock.Raise.PacketReceived(new Packet(1, [])); - - await That(Act).DoesNotThrow(); - } +#endif } +#if NET8_0_OR_GREATER public sealed class SpanPropertyTests { + public interface ISpanBuffer + { + Span Buffer { get; set; } + } + [Fact] public async Task Getter_ShouldReturnConfiguredSpan() { @@ -112,7 +119,9 @@ public async Task Setter_ShouldBeRecorded() await That(sut.Mock.Verify.Buffer.Set(It.IsAny>())).Once(); } } +#endif +#pragma warning disable Mockolate0003 // Ref-struct usage is not supported on this compilation public sealed class UnsupportedValuePositionTests { [Fact] @@ -162,7 +171,7 @@ await That(Act).Throws() [Fact] public async Task RefStructParameterDelegate_ShouldThrowNotSupported() { - PacketConsumer sut = PacketConsumer.CreateMock(); + PacketHandler sut = PacketHandler.CreateMock(); void Act() => sut(new Packet(1, [])); @@ -170,5 +179,5 @@ await That(Act).Throws() .WithMessage("*ref-struct parameters are not supported on delegate types*").AsWildcard(); } } +#pragma warning restore Mockolate0003 } -#endif From ff63f02b1a29acdc951f8b8c3e71e223817377a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 15 Sep 2026 11:39:01 +0200 Subject: [PATCH 4/8] fix: support Span-valued indexers and keep virtual ref-struct members usable A Span-valued indexer setter inferred `ApplyIndexerSetter>` 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. --- Docs/pages/08-analyzers.md | 7 +- .../Entities/Method.cs | 2 +- Source/Mockolate.SourceGenerators/Helpers.cs | 3 +- .../Sources/Sources.MockClass.cs | 137 +++++++++++++++-- .../Sources/Sources.MockDelegate.cs | 2 +- .../MockabilityAnalyzerRefStructTests.cs | 25 ++++ .../MockTests.ClassTests.IndexerTests.cs | 14 +- .../Mock.IComprehensiveInterface.g.cs | 8 +- .../Mock.IKeywordEdgeCases.g.cs | 2 +- .../RefStruct/RefStructValuePositionTests.cs | 139 +++++++++++++++++- .../TestHelpers/RefStruct/IBigPacketParser.cs | 2 +- .../RefStruct/IBigPacketSetter5.cs | 2 +- .../TestHelpers/RefStruct/IBigPacketSink.cs | 2 +- .../TestHelpers/RefStruct/IBigPacketStore5.cs | 2 +- .../RefStruct/IGeneratedDoublePacketStore.cs | 2 +- .../RefStruct/IGeneratedMixedStore.cs | 2 +- .../RefStruct/IGeneratedPacketLookup.cs | 7 +- .../RefStruct/IGeneratedPacketParser.cs | 2 +- .../RefStruct/IGeneratedPacketSetter.cs | 2 +- .../RefStruct/IGeneratedPacketSink.cs | 2 +- .../RefStruct/IGeneratedPacketStore.cs | 2 +- .../RefStruct/IGeneratedPacketWriter.cs | 2 +- .../TestHelpers/RefStruct/Packet.cs | 2 +- 23 files changed, 318 insertions(+), 52 deletions(-) diff --git a/Docs/pages/08-analyzers.md b/Docs/pages/08-analyzers.md index 718872d8d..2604b45be 100644 --- a/Docs/pages/08-analyzers.md +++ b/Docs/pages/08-analyzers.md @@ -110,9 +110,10 @@ Also never supported: `VoidMethodSetup` / `ReturnMethodSetup`, neither of which carries the anti-constraint the interface and class pipelines get from `RefStructVoidMethodSetup`. -Members matching any of these still compile: the generated mock keeps the member but its body throws -`NotSupportedException`, and no setup or verify surface is emitted for it. Everything else on the -type stays mockable. +Members matching any of these still compile: the generated mock keeps the member, but no setup or +verify surface is emitted for it. A `virtual` member on a mocked class forwards to the wrapped +instance (or to `base`) so it keeps behaving like the real one; an interface or `abstract` member has +nothing to forward to and throws `NotSupportedException`. Everything else on the type stays mockable. **Note:** `Span` and `ReadOnlySpan` flow through the existing `SpanWrapper` / `ReadOnlySpanWrapper` diff --git a/Source/Mockolate.SourceGenerators/Entities/Method.cs b/Source/Mockolate.SourceGenerators/Entities/Method.cs index 5785e02f4..3bb42d205 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Method.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Method.cs @@ -74,7 +74,7 @@ public Method(IMethodSymbol methodSymbol, List? alreadyDefinedMethods, I /// and no setup/verify surface; Span<T>/ReadOnlySpan<T> parameters are /// exempt because they flow through their wrappers. /// - public bool HasUnsupportedRefStructParameter + public bool IsDelegateWithUnsupportedRefStructParameter => IsDelegateInvoke && Parameters.Any(parameter => parameter.NeedsRefStructPipeline()); public bool IsProtected => Accessibility is Accessibility.Protected or Accessibility.ProtectedOrInternal diff --git a/Source/Mockolate.SourceGenerators/Helpers.cs b/Source/Mockolate.SourceGenerators/Helpers.cs index 68f764a0a..02a2bf13e 100644 --- a/Source/Mockolate.SourceGenerators/Helpers.cs +++ b/Source/Mockolate.SourceGenerators/Helpers.cs @@ -234,7 +234,8 @@ public static string ToTypeOrWrapper(this Type type) /// IPropertyGetterOnlySetup<T>, IIndexerGetterOnlySetup<TValue, ...>), /// which is illegal for a ref struct, so they cannot carry the allows ref struct /// anti-constraint the parameter positions use. Members matching this in a value position get - /// a NotSupportedException stub and no setup/verify surface. + /// no setup/verify surface: a virtual class member forwards to the wrapped instance or to + /// base, everything else gets a NotSupportedException stub. /// /// public static bool NeedsRefStructPipeline(this Type type) diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs index 71507d433..b28165af7 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs @@ -1755,17 +1755,28 @@ property.IndexerParameters is not null : property.Name).AppendLine(); } - // Ref-struct-keyed indexer (getter AND setter): the full access pipeline requires the - // key value to flow through IndexerGetterAccess / IndexerSetterAccess (classes with - // field slots), which is illegal for a ref-struct key. Emit a runtime - // NotSupportedException from both accessor bodies; the analyzer flags the declaration - // at compile time. Full getter wiring to RefStructIndexerGetterSetup is future work. + // Ref-struct-keyed indexer: the key cannot flow through IndexerGetterAccess / + // IndexerSetterAccess (classes with field slots), so both accessors dispatch through the + // RefStructIndexerGetterSetup / RefStructIndexerSetterSetup pipeline instead. That pipeline + // is #if NET9_0_OR_GREATER-gated; below it the emitted body is an #error and the analyzer + // reports the declaration. bool isRefStructIndexer = property is { IsIndexer: true, IndexerParameters: not null, } && property.IndexerParameters.Value.Any(p => p.NeedsRefStructPipeline()); // A non-span ref-struct property or indexer value has no setup surface either (see - // Helpers.NeedsRefStructPipeline), so both accessors throw for the same reason. + // Helpers.NeedsRefStructPipeline). A virtual class member still has a real implementation + // behind it, so the accessor forwards to the wrapped instance or to base rather than + // breaking a member that would otherwise work; interface and abstract members have nothing + // to forward to and throw. bool isUnsupportedRefStructValue = property.Type.NeedsRefStructPipeline(); + bool forwardsUnsupportedRefStructValue = isUnsupportedRefStructValue && !isClassInterface && + property is { IsAbstract: false, IsStatic: false, }; + string unsupportedWrapsVarName = property.IndexerParameters is null + ? "wraps" + : Helpers.GetUniqueLocalVariableName("wraps", property.IndexerParameters.Value); + string unsupportedMemberAccess = property.IndexerParameters is null + ? "." + property.Name + : "[" + FormatIndexerParametersAsNames(property.IndexerParameters.Value) + "]"; void AppendUnsupportedRefStructValueThrow() => sb.Append("\t\t\t\tthrow new global::System.NotSupportedException(\"Mockolate: ") @@ -1775,6 +1786,52 @@ void AppendUnsupportedRefStructValueThrow() .Append(property.ContainingType).Append('.').Append(property.Name).Append("'.\");") .AppendLine(); + void AppendUnsupportedRefStructValueGetter() + { + if (!forwardsUnsupportedRefStructValue) + { + AppendUnsupportedRefStructValueThrow(); + } + else if (property.Getter?.IsProtected == true) + { + sb.Append("\t\t\t\treturn base").Append(unsupportedMemberAccess).Append(';').AppendLine(); + } + else + { + sb.Append("\t\t\t\treturn ").Append(mockRegistry).Append(".Wraps is ").Append(wrapsType).Append(' ') + .Append(unsupportedWrapsVarName).Append(" ? ").Append(unsupportedWrapsVarName) + .Append(unsupportedMemberAccess).Append(" : base").Append(unsupportedMemberAccess).Append(';') + .AppendLine(); + } + } + + void AppendUnsupportedRefStructValueSetter() + { + // An init-only accessor cannot assign through `wraps`, and the surrounding pipeline + // excludes it from wrapping for the same reason, so it keeps the throw. + if (!forwardsUnsupportedRefStructValue || property.Setter?.IsInitOnly == true) + { + AppendUnsupportedRefStructValueThrow(); + } + else if (property.Setter?.IsProtected == true) + { + sb.Append("\t\t\t\tbase").Append(unsupportedMemberAccess).Append(" = value;").AppendLine(); + } + else + { + sb.Append("\t\t\t\tif (").Append(mockRegistry).Append(".Wraps is ").Append(wrapsType).Append(' ') + .Append(unsupportedWrapsVarName).Append(')').AppendLine(); + sb.Append("\t\t\t\t{").AppendLine(); + sb.Append("\t\t\t\t\t").Append(unsupportedWrapsVarName).Append(unsupportedMemberAccess) + .Append(" = value;").AppendLine(); + sb.Append("\t\t\t\t}").AppendLine(); + sb.Append("\t\t\t\telse").AppendLine(); + sb.Append("\t\t\t\t{").AppendLine(); + sb.Append("\t\t\t\t\tbase").Append(unsupportedMemberAccess).Append(" = value;").AppendLine(); + sb.Append("\t\t\t\t}").AppendLine(); + } + } + sb.AppendLine("\t\t{"); if (property.Getter != null) { @@ -1789,7 +1846,7 @@ void AppendUnsupportedRefStructValueThrow() if (isUnsupportedRefStructValue) { - AppendUnsupportedRefStructValueThrow(); + AppendUnsupportedRefStructValueGetter(); } else if (isRefStructIndexer) { @@ -2028,7 +2085,7 @@ void AppendUnsupportedRefStructValueThrow() if (isUnsupportedRefStructValue) { - AppendUnsupportedRefStructValueThrow(); + AppendUnsupportedRefStructValueSetter(); } // Ref-struct-keyed indexer setter: dispatches through // RefStructIndexerSetterSetup. Mirrors the getter pipeline. @@ -2052,7 +2109,8 @@ void AppendUnsupportedRefStructValueThrow() useFastForIndexer ? indexerSetIdRef : null, useFastForIndexer ? indexerGetIdRef : null, indexerSetCachedBufferRef); - sb.Append("\t\t\t\t").Append(mockRegistry).Append(".ApplyIndexerSetter(") + sb.Append("\t\t\t\t").Append(mockRegistry).Append(".ApplyIndexerSetter<") + .AppendTypeOrWrapper(property.Type).Append(">(") .Append(accessVarName).Append(", ").Append(setupVarName).Append(", value, ") .Append(signatureIndex).Append(");") .AppendLine(); @@ -2109,7 +2167,8 @@ void AppendUnsupportedRefStructValueThrow() useFastForIndexer ? indexerSetIdRef : null, useFastForIndexer ? indexerGetIdRef : null, indexerSetCachedBufferRef); - sb.Append("\t\t\t\tif (!").Append(mockRegistry).Append(".ApplyIndexerSetter(") + sb.Append("\t\t\t\tif (!").Append(mockRegistry).Append(".ApplyIndexerSetter<") + .AppendTypeOrWrapper(property.Type).Append(">(") .Append(accessVarName).Append(", ").Append(setupVarName).Append(", value, ") .Append(signatureIndex).Append("))").AppendLine(); sb.Append("\t\t\t\t{").AppendLine(); @@ -2145,7 +2204,8 @@ void AppendUnsupportedRefStructValueThrow() useFastForIndexer ? indexerSetIdRef : null, useFastForIndexer ? indexerGetIdRef : null, indexerSetCachedBufferRef); - sb.Append("\t\t\t\t").Append(mockRegistry).Append(".ApplyIndexerSetter(") + sb.Append("\t\t\t\t").Append(mockRegistry).Append(".ApplyIndexerSetter<") + .AppendTypeOrWrapper(property.Type).Append(">(") .Append(accessVarName).Append(", ").Append(setupVarName).Append(", value, ") .Append(signatureIndex).Append(");").AppendLine(); } @@ -2345,7 +2405,8 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb, // break the whole compilation over a member that can never be supported anyway. if (method.ReturnType.NeedsRefStructPipeline()) { - AppendUnsupportedMethodThrow(sb, method, "methods returning a non-span ref struct are not supported"); + AppendUnsupportedRefStructReturnBody(sb, method, mockRegistry, className, isClassInterface, + explicitInterfaceImplementation); sb.AppendLine("\t\t}"); return; } @@ -3059,6 +3120,41 @@ private static void AppendUnsupportedMethodThrow(StringBuilder sb, Method method .Append(". Method '").Append(method.ContainingType).Append('.').Append(method.Name) .Append("'.\");").AppendLine(); + /// + /// Emits the body of a method whose non-span ref-struct return leaves it without a setup surface. + /// A virtual class method still has a real implementation behind it, so it forwards to the wrapped + /// instance or to base rather than breaking a member that would otherwise work. Interface, + /// abstract, static and ref-returning methods have nothing to forward to and throw. + /// + /// + /// The forward ignores MockBehavior.SkipBaseClass: without a setup there is no configured + /// value to return in its place, and a ref-struct return cannot be recorded either way. + /// + private static void AppendUnsupportedRefStructReturnBody(StringBuilder sb, Method method, string mockRegistry, + string className, bool isClassInterface, bool explicitInterfaceImplementation) + { + const string reason = "methods returning a non-span ref struct are not supported"; + if (isClassInterface || method.IsAbstract || method.IsStatic || method.IsRefReturn || + method.IsRefReadonlyReturn) + { + AppendUnsupportedMethodThrow(sb, method, reason); + return; + } + + // Method.Name already carries the type-parameter list for generic methods. + string call = method.Name + "(" + FormatMethodParametersWithRefKind(method.Parameters) + ")"; + if (explicitInterfaceImplementation || method.IsProtected) + { + sb.Append("\t\t\treturn base.").Append(call).Append(';').AppendLine(); + return; + } + + string wraps = Helpers.GetUniqueLocalVariableName("wraps", method.Parameters); + sb.Append("\t\t\treturn ").Append(mockRegistry).Append(".Wraps is ").Append(className).Append(' ') + .Append(wraps).Append(" ? ").Append(wraps).Append('.').Append(call).Append(" : base.").Append(call) + .Append(';').AppendLine(); + } + #endregion Mock Helpers #region Setup Helpers @@ -3510,7 +3606,7 @@ private static void AppendMethodSetupDefinition(StringBuilder sb, Class @class, // NotSupportedException stub, so no setup is needed. if (method.HasUnsupportedAllowsRefStructTypeParameter || method.ReturnType.NeedsRefStructPipeline() || - method.HasUnsupportedRefStructParameter) + method.IsDelegateWithUnsupportedRefStructParameter) { return; } @@ -3890,7 +3986,7 @@ private static void AppendMethodSetupImplementation(StringBuilder sb, Method met // explicit implementation is emitted either. if (method.HasUnsupportedAllowsRefStructTypeParameter || method.ReturnType.NeedsRefStructPipeline() || - method.HasUnsupportedRefStructParameter) + method.IsDelegateWithUnsupportedRefStructParameter) { return; } @@ -5357,8 +5453,9 @@ private static void AppendMethodVerifyDefinition(StringBuilder sb, Method method // for count-based verification. See brief's "verify count" guidance. // // A non-span ref-struct return is skipped for a different reason: the body is a - // NotSupportedException stub, so the call never reaches interaction recording and the - // surface could only ever report zero. Matches the property/indexer carve-out. + // NotSupportedException stub (or, for a virtual class method, a passthrough to base), so the + // call never reaches interaction recording and the surface could only ever report zero. + // Matches the property/indexer carve-out. if (method.Parameters.Any(p => p.NeedsRefStructPipeline()) || method.ReturnType.NeedsRefStructPipeline()) { return; @@ -6044,6 +6141,9 @@ private static bool IsFastBufferEligibleEvent(Event @event) /// /// Methods with non-generic, non-ref-struct signatures get a typed per-member buffer; everything /// else (open generics, ref-struct params) records via the legacy RegisterInteraction fallback. + /// A non-span ref-struct return is excluded because its body never records - it is a + /// NotSupportedException stub, or a passthrough to base - so the buffer field would be + /// emitted and never read. /// private static bool IsFastBufferEligibleMethod(Method method) { @@ -6052,6 +6152,11 @@ private static bool IsFastBufferEligibleMethod(Method method) return false; } + if (method.ReturnType.NeedsRefStructPipeline()) + { + return false; + } + foreach (MethodParameter parameter in method.Parameters) { if (parameter.NeedsRefStructPipeline()) diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs index 2434a36cf..cde8b0cf8 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockDelegate.cs @@ -388,7 +388,7 @@ private static void AppendMockDelegateInvokeBody(StringBuilder sb, Method delega return; } - if (delegateMethod.HasUnsupportedRefStructParameter) + if (delegateMethod.IsDelegateWithUnsupportedRefStructParameter) { AppendUnsupportedMethodThrow(sb, delegateMethod, "ref-struct parameters are not supported on delegate types"); diff --git a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs index df61c9b62..4b73f02d3 100644 --- a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs +++ b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs @@ -559,6 +559,31 @@ public void MyTest() """ ); + [Fact] + public async Task WhenMockingInterfaceWithSpanValuedIndexer_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.ISpanCatalog")}} + + namespace MyNamespace + { + public interface ISpanCatalog + { + // Span-valued indexers round-trip through SpanWrapper on both accessors. + System.Span this[int index] { get; set; } + } + + public class MyClass + { + public void MyTest() + { + ISpanCatalog.CreateMock(); + } + } + } + """ + ); + [Fact] public async Task WhenMockingInterfaceWithRefStructValuedIndexer_ShouldBeFlagged() => await Verifier .VerifyAnalyzerAsync( diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.ClassTests.IndexerTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.ClassTests.IndexerTests.cs index 413883998..0881f16f1 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.ClassTests.IndexerTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.ClassTests.IndexerTests.cs @@ -230,7 +230,7 @@ public int this[int index] } global::Mockolate.Interactions.IndexerSetterAccess access = new(index, value); setup ??= this.MockRegistry.GetIndexerSetup>(access); - this.MockRegistry.ApplyIndexerSetter(access, setup, value, 0); + this.MockRegistry.ApplyIndexerSetter(access, setup, value, 0); if (this.MockRegistry.Wraps is global::MyCode.IMyService wraps) { wraps[index] = value; @@ -305,7 +305,7 @@ public int this[int index] } global::Mockolate.Interactions.IndexerSetterAccess access = new(index, isWriteOnly, value); setup ??= this.MockRegistry.GetIndexerSetup>(access); - this.MockRegistry.ApplyIndexerSetter(access, setup, value, 2); + this.MockRegistry.ApplyIndexerSetter(access, setup, value, 2); if (this.MockRegistry.Wraps is global::MyCode.IMyService wraps) { wraps[index, isWriteOnly] = value; @@ -383,7 +383,7 @@ public override int this[int index] this.MockRegistry.RegisterInteraction(access); } global::Mockolate.Setup.IndexerSetup? setup = this.MockRegistry.GetIndexerSetup>(access); - if (!this.MockRegistry.ApplyIndexerSetter(access, setup, value, 0)) + if (!this.MockRegistry.ApplyIndexerSetter(access, setup, value, 0)) { if (this.MockRegistry.Wraps is global::MyCode.MyService wraps) { @@ -432,7 +432,7 @@ public override int this[int index] this.MockRegistry.RegisterInteraction(access); } global::Mockolate.Setup.IndexerSetup? setup = this.MockRegistry.GetIndexerSetup>(access); - if (!this.MockRegistry.ApplyIndexerSetter(access, setup, value, 2)) + if (!this.MockRegistry.ApplyIndexerSetter(access, setup, value, 2)) { base[index, isWriteOnly] = value; } @@ -464,7 +464,7 @@ public override int this[int index] this.MockRegistry.RegisterInteraction(access); } global::Mockolate.Setup.IndexerSetup? setup = this.MockRegistry.GetIndexerSetup>(access); - this.MockRegistry.ApplyIndexerSetter(access, setup, value, 3); + this.MockRegistry.ApplyIndexerSetter(access, setup, value, 3); } } """).IgnoringNewlineStyle(); @@ -589,7 +589,7 @@ await That(result.Sources["Mock.IMyService.g.cs"]) } } setup ??= this.MockRegistry.GetIndexerSetup>>(access); - this.MockRegistry.ApplyIndexerSetter(access, setup, value, 0); + this.MockRegistry.ApplyIndexerSetter(access, setup, value, 0); if (this.MockRegistry.Wraps is global::MyCode.IMyService wraps) { wraps[buffer] = value; @@ -658,7 +658,7 @@ await That(result.Sources["Mock.IMyService.g.cs"]) } } setup ??= this.MockRegistry.GetIndexerSetup>>(access); - this.MockRegistry.ApplyIndexerSetter(access, setup, value, 1); + this.MockRegistry.ApplyIndexerSetter(access, setup, value, 1); if (this.MockRegistry.Wraps is global::MyCode.IMyService wraps) { wraps[values] = value; diff --git a/Tests/Mockolate.SourceGenerators.Tests/Snapshot/Expected/ComprehensiveInterface_CanBeCreated/Mock.IComprehensiveInterface.g.cs b/Tests/Mockolate.SourceGenerators.Tests/Snapshot/Expected/ComprehensiveInterface_CanBeCreated/Mock.IComprehensiveInterface.g.cs index d5c29a481..42ee1afe0 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/Snapshot/Expected/ComprehensiveInterface_CanBeCreated/Mock.IComprehensiveInterface.g.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/Snapshot/Expected/ComprehensiveInterface_CanBeCreated/Mock.IComprehensiveInterface.g.cs @@ -482,7 +482,7 @@ public string this[int i] } global::Mockolate.Interactions.IndexerSetterAccess access = new(i, value); setup ??= this.MockRegistry.GetIndexerSetup>(access); - this.MockRegistry.ApplyIndexerSetter(access, setup, value, 0); + this.MockRegistry.ApplyIndexerSetter(access, setup, value, 0); if (this.MockRegistry.Wraps is global::Mockolate.Tests.GeneratorCoverage.IComprehensiveInterface wraps) { wraps[i] = value; @@ -518,7 +518,7 @@ public string this[int i] this.MockRegistry.RegisterInteraction(access); } global::Mockolate.Setup.IndexerSetup? setup = this.MockRegistry.GetIndexerSetup>(access); - this.MockRegistry.ApplyIndexerSetter(access, setup, value, 1); + this.MockRegistry.ApplyIndexerSetter(access, setup, value, 1); if (this.MockRegistry.Wraps is global::Mockolate.Tests.GeneratorCoverage.IComprehensiveInterface wraps) { wraps[a, b, c, d, e] = value; @@ -559,7 +559,7 @@ public string this[int i] this.MockRegistry.RegisterInteraction(access); } global::Mockolate.Setup.IndexerSetup? setup = this.MockRegistry.GetIndexerSetup>(access); - this.MockRegistry.ApplyIndexerSetter(access, setup, value, 3); + this.MockRegistry.ApplyIndexerSetter(access, setup, value, 3); if (this.MockRegistry.Wraps is global::Mockolate.Tests.GeneratorCoverage.IComprehensiveInterface wraps) { wraps[a, b, c, d, e] = value; @@ -632,7 +632,7 @@ public string this[char key] } global::Mockolate.Interactions.IndexerSetterAccess access = new(key, value); setup ??= this.MockRegistry.GetIndexerSetup>(access); - this.MockRegistry.ApplyIndexerSetter(access, setup, value, 5); + this.MockRegistry.ApplyIndexerSetter(access, setup, value, 5); if (this.MockRegistry.Wraps is global::Mockolate.Tests.GeneratorCoverage.IComprehensiveInterface wraps) { wraps[key] = value; diff --git a/Tests/Mockolate.SourceGenerators.Tests/Snapshot/Expected/KeywordEdgeCases_CanBeCreated/Mock.IKeywordEdgeCases.g.cs b/Tests/Mockolate.SourceGenerators.Tests/Snapshot/Expected/KeywordEdgeCases_CanBeCreated/Mock.IKeywordEdgeCases.g.cs index 7c537db53..844b2decf 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/Snapshot/Expected/KeywordEdgeCases_CanBeCreated/Mock.IKeywordEdgeCases.g.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/Snapshot/Expected/KeywordEdgeCases_CanBeCreated/Mock.IKeywordEdgeCases.g.cs @@ -229,7 +229,7 @@ public int @class } global::Mockolate.Interactions.IndexerSetterAccess access = new(@params, @void, value); setup ??= this.MockRegistry.GetIndexerSetup>(access); - this.MockRegistry.ApplyIndexerSetter(access, setup, value, 0); + this.MockRegistry.ApplyIndexerSetter(access, setup, value, 0); if (this.MockRegistry.Wraps is global::Mockolate.Tests.GeneratorCoverage.IKeywordEdgeCases wraps) { wraps[@params, @void] = value; diff --git a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs index e1c761d6e..21eb3de3d 100644 --- a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs +++ b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs @@ -6,8 +6,9 @@ namespace Mockolate.Tests.RefStruct; /// /// Covers ref structs in value positions - event arguments, method and delegate returns, /// property types and indexer values. These once made the generator emit uncompilable code -/// (CS9244); they now either flow through the Span wrapper or degrade to a -/// stub with no setup surface. +/// (CS9244); they now either flow through the Span wrapper or lose their setup surface - a +/// virtual class member then forwards to the wrapped instance or to base, everything +/// else throws . /// /// /// Deliberately not gated to net9.0+: none of these shapes route through the ref-struct setup @@ -119,9 +120,143 @@ public async Task Setter_ShouldBeRecorded() await That(sut.Mock.Verify.Buffer.Set(It.IsAny>())).Once(); } } + + public sealed class SpanIndexerTests + { + public interface ISpanBufferGetter + { + Span this[int index] { get; } + } + + public interface ISpanBufferSetter + { + Span this[int index] { set; } + } + + public interface ISpanBufferStore + { + Span this[int index] { get; set; } + } + + [Fact] + public async Task GetterOnly_ShouldReturnConfiguredSpan() + { + ISpanBufferGetter sut = ISpanBufferGetter.CreateMock(); + byte[] expected = [1, 2, 3,]; + sut.Mock.Setup[2].Returns(expected.AsSpan()); + + byte[] result = sut[2].ToArray(); + + await That(result).IsEqualTo(expected) + .Because("a Span-valued indexer round-trips through SpanWrapper"); + } + + [Fact] + public async Task SetterOnly_ShouldBeRecorded() + { + ISpanBufferSetter sut = ISpanBufferSetter.CreateMock(); + + sut[4] = new byte[] { 9, 8, }.AsSpan(); + + await That(sut.Mock.Verify[4].Set(It.IsAny>())).Once() + .Because("the setter dispatches through ApplyIndexerSetter>"); + } + + [Fact] + public async Task GetAndSet_ShouldBeRecorded() + { + ISpanBufferStore sut = ISpanBufferStore.CreateMock(); + + sut[7] = new byte[] { 5, }.AsSpan(); + + await That(sut.Mock.Verify[7].Set(It.IsAny>())).Once(); + } + } #endif + public class PacketSource + { + public virtual Packet Current => new(11, []); + + public virtual Packet Produce() => new(12, []); + } + + public abstract class AbstractPacketSource + { + public abstract Packet Current { get; } + } + #pragma warning disable Mockolate0003 // Ref-struct usage is not supported on this compilation + /// + /// A ref struct in a value position has no setup surface, but a virtual class member still + /// has a real implementation behind it and keeps working. + /// + public sealed class ClassPassthroughTests + { + private sealed class LoudPacketSource : PacketSource + { + public override Packet Current => new(99, []); + + public override Packet Produce() => new(98, []); + } + + [Fact] + public async Task VirtualProperty_ShouldFallBackToBase() + { + PacketSource sut = PacketSource.CreateMock(); + + int id = sut.Current.Id; + + await That(id).IsEqualTo(11) + .Because("a virtual member without a setup surface forwards to base instead of throwing"); + } + + [Fact] + public async Task VirtualMethod_ShouldFallBackToBase() + { + PacketSource sut = PacketSource.CreateMock(); + + int id = sut.Produce().Id; + + await That(id).IsEqualTo(12) + .Because("a virtual member without a setup surface forwards to base instead of throwing"); + } + + [Fact] + public async Task VirtualProperty_WhenWrapping_ShouldUseWrappedInstance() + { + PacketSource sut = PacketSource.CreateMock().Wrapping(new LoudPacketSource()); + + int id = sut.Current.Id; + + await That(id).IsEqualTo(99) + .Because("the passthrough honours Wraps the same way every other member does"); + } + + [Fact] + public async Task VirtualMethod_WhenWrapping_ShouldUseWrappedInstance() + { + PacketSource sut = PacketSource.CreateMock().Wrapping(new LoudPacketSource()); + + int id = sut.Produce().Id; + + await That(id).IsEqualTo(98) + .Because("the passthrough honours Wraps the same way every other member does"); + } + + [Fact] + public async Task AbstractProperty_ShouldThrowNotSupported() + { + AbstractPacketSource sut = AbstractPacketSource.CreateMock(); + + void Act() => _ = sut.Current; + + await That(Act).Throws() + .WithMessage("*properties of a non-span ref struct type are not supported*").AsWildcard() + .Because("an abstract member has no implementation to forward to"); + } + } + public sealed class UnsupportedValuePositionTests { [Fact] diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketParser.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketParser.cs index ff9e5b948..799f43208 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketParser.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketParser.cs @@ -8,4 +8,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IBigPacketParser { int TryParse(Packet p1, Packet p2, int offset, Packet p4, Packet p5, string format); -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSetter5.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSetter5.cs index 9a718cfea..3405f9730 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSetter5.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSetter5.cs @@ -9,4 +9,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IBigPacketSetter5 { string this[Packet k1, int a, Packet k2, string b, Packet k3] { set; } -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSink.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSink.cs index 089e8e0a8..1ddde52f7 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSink.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketSink.cs @@ -8,4 +8,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IBigPacketSink { void Absorb(Packet p1, Packet p2, Packet p3, Packet p4, Packet p5); -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketStore5.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketStore5.cs index 2c0584a3f..c59c379e9 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketStore5.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IBigPacketStore5.cs @@ -8,4 +8,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IBigPacketStore5 { string this[Packet k1, int a, Packet k2, string b, Packet k3] { get; set; } -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedDoublePacketStore.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedDoublePacketStore.cs index 1c5ce7ef9..93b1ead4c 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedDoublePacketStore.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedDoublePacketStore.cs @@ -7,4 +7,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IGeneratedDoublePacketStore { string this[Packet k1, Packet k2] { get; set; } -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedMixedStore.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedMixedStore.cs index 04ad6a9e5..c1d8e17ac 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedMixedStore.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedMixedStore.cs @@ -7,4 +7,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IGeneratedMixedStore { string this[int priority, Packet key] { get; set; } -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketLookup.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketLookup.cs index 66f458944..c48fe8364 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketLookup.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketLookup.cs @@ -1,11 +1,10 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; /// -/// Generator-target: indexer keyed by a ref struct. Not wired up in commit E — both -/// accessors throw NotSupportedException. The analyzer will flag this pattern at -/// compile time in commit F. +/// Generator-target: getter-only indexer keyed by a ref struct. Exercises the standalone +/// IRefStructIndexerGetterSetup<TValue, T> facade. /// public interface IGeneratedPacketLookup { string this[Packet key] { get; } -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketParser.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketParser.cs index bb2b405b9..1401db381 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketParser.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketParser.cs @@ -7,4 +7,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IGeneratedPacketParser { int TryParse(Packet packet); -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSetter.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSetter.cs index 09ef6b166..e1dd3498a 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSetter.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSetter.cs @@ -7,4 +7,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IGeneratedPacketSetter { string this[Packet key] { set; } -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSink.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSink.cs index 88aac0773..b1daa9820 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSink.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketSink.cs @@ -7,4 +7,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IGeneratedPacketSink { void Consume(Packet packet); -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketStore.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketStore.cs index e20790215..421ae36dc 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketStore.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketStore.cs @@ -7,4 +7,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IGeneratedPacketStore { string this[Packet key] { get; set; } -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketWriter.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketWriter.cs index ab5c1ab65..c40b31812 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketWriter.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/IGeneratedPacketWriter.cs @@ -7,4 +7,4 @@ namespace Mockolate.Tests.TestHelpers.RefStruct; public interface IGeneratedPacketWriter { void Write(Packet packet, int priority); -} \ No newline at end of file +} diff --git a/Tests/Mockolate.Tests/TestHelpers/RefStruct/Packet.cs b/Tests/Mockolate.Tests/TestHelpers/RefStruct/Packet.cs index ce90bc20e..8cd8ce8b1 100644 --- a/Tests/Mockolate.Tests/TestHelpers/RefStruct/Packet.cs +++ b/Tests/Mockolate.Tests/TestHelpers/RefStruct/Packet.cs @@ -7,4 +7,4 @@ public readonly ref struct Packet(int id, ReadOnlySpan payload) { public int Id { get; } = id; public ReadOnlySpan Payload { get; } = payload; -} \ No newline at end of file +} From cce165b84145aa836300f7f1367a2569c0babfa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 15 Sep 2026 11:39:05 +0200 Subject: [PATCH 5/8] docs: correct the ref-struct rejection list in parameter matching 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. --- Docs/pages/setup/04-parameter-matching.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Docs/pages/setup/04-parameter-matching.md b/Docs/pages/setup/04-parameter-matching.md index 483b5bc17..1800d63c2 100644 --- a/Docs/pages/setup/04-parameter-matching.md +++ b/Docs/pages/setup/04-parameter-matching.md @@ -264,12 +264,20 @@ 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`: +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. -- 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` / `ReadOnlySpan` returns are supported.) +The following cases are flagged with diagnostic `Mockolate0003`: + +- Targeting older than .NET 9, or pinning `` 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` / `ReadOnlySpan` are supported in those positions.) +- Ref-struct parameters on delegate types. + +Members in the latter two groups still compile - the mock keeps the member but emits no setup or +verify surface for it. See [Mockolate0003](../analyzers#mockolate0003) for what the member does at +runtime. ## Parameter Predicates From 3092785d1ae38b92be946ca766286bd61cc55d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 15 Sep 2026 22:12:27 +0200 Subject: [PATCH 6/8] fix: make ref-struct value positions match and stop flagging working members Span-valued properties and indexers were compilable but silently unverifiable: the value is matched through `EqualityComparer>.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. --- Docs/pages/08-analyzers.md | 15 +- Docs/pages/setup/04-parameter-matching.md | 5 +- .../AnalyzerReleases.Shipped.md | 2 +- .../MockabilityAnalyzer.cs | 46 ++- .../Sources/Sources.MockClass.cs | 23 +- Source/Mockolate/Setup/ReadOnlySpanWrapper.cs | 27 ++ Source/Mockolate/Setup/SpanWrapper.cs | 27 ++ .../MockabilityAnalyzerRefStructTests.cs | 208 ++++++++++++ .../Expected/Mockolate_net10.0.txt | 6 + .../Expected/Mockolate_net8.0.txt | 6 + .../RefStruct/RefStructValuePositionTests.cs | 319 +++++++++++++++++- .../Setup/ReadOnlySpanWrapperTests.cs | 78 +++++ .../Mockolate.Tests/Setup/SpanWrapperTests.cs | 78 +++++ 13 files changed, 812 insertions(+), 28 deletions(-) create mode 100644 Tests/Mockolate.Tests/Setup/ReadOnlySpanWrapperTests.cs create mode 100644 Tests/Mockolate.Tests/Setup/SpanWrapperTests.cs diff --git a/Docs/pages/08-analyzers.md b/Docs/pages/08-analyzers.md index 2604b45be..a2b1eddcb 100644 --- a/Docs/pages/08-analyzers.md +++ b/Docs/pages/08-analyzers.md @@ -111,9 +111,18 @@ Also never supported: interface and class pipelines get from `RefStructVoidMethodSetup`. Members matching any of these still compile: the generated mock keeps the member, but no setup or -verify surface is emitted for it. A `virtual` member on a mocked class forwards to the wrapped -instance (or to `base`) so it keeps behaving like the real one; an interface or `abstract` member has -nothing to forward to and throws `NotSupportedException`. Everything else on the type stays mockable. +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` and `ReadOnlySpan` flow through the existing `SpanWrapper` / `ReadOnlySpanWrapper` diff --git a/Docs/pages/setup/04-parameter-matching.md b/Docs/pages/setup/04-parameter-matching.md index 1800d63c2..ddf9b7c3d 100644 --- a/Docs/pages/setup/04-parameter-matching.md +++ b/Docs/pages/setup/04-parameter-matching.md @@ -276,8 +276,9 @@ The following cases are flagged with diagnostic `Mockolate0003`: - Ref-struct parameters on delegate types. Members in the latter two groups still compile - the mock keeps the member but emits no setup or -verify surface for it. See [Mockolate0003](../analyzers#mockolate0003) for what the member does at -runtime. +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 diff --git a/Source/Mockolate.Analyzers/AnalyzerReleases.Shipped.md b/Source/Mockolate.Analyzers/AnalyzerReleases.Shipped.md index 9ebac30cd..a0e94e695 100644 --- a/Source/Mockolate.Analyzers/AnalyzerReleases.Shipped.md +++ b/Source/Mockolate.Analyzers/AnalyzerReleases.Shipped.md @@ -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 diff --git a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs index bc48c9280..f43d5afae 100644 --- a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs +++ b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs @@ -151,7 +151,8 @@ when TryGetRefStructIssueForIndexer(p, pipelineUnsupportedReason, out string? is "this[]", issue)); break; - case IPropertySymbol { IsIndexer: false, } p when NeedsRefStructPipeline(p.Type): + case IPropertySymbol { IsIndexer: false, } p + when NeedsRefStructPipeline(p.Type) && !ForwardsToBaseImplementation(p): context.ReportDiagnostic(Diagnostic.Create( s_refStructRule, location, @@ -274,6 +275,34 @@ private static bool NeedsRefStructPipeline(ITypeSymbol type) type.Name is "Span" or "ReadOnlySpan"); } + /// + /// Whether the generator degrades to a passthrough rather than a + /// NotSupportedException stub, in which case the mock keeps behaving like the real member + /// and there is nothing for the user to act on. Mirrors forwardsUnsupportedRefStructValue + /// and AppendUnsupportedRefStructReturnBody in Sources.MockClass: 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 base. Static members need no handling here: + /// already drops them, since a class member the + /// generator overrides is necessarily abstract or virtual. + /// + 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, + }; + } + /// /// Returns a human-readable reason when the current compilation cannot host the /// ref-struct pipeline, or when it is supported. Both the target @@ -308,6 +337,14 @@ private static bool TryGetRefStructIssue(IMethodSymbol method, string? pipelineU // 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; } @@ -355,6 +392,13 @@ private static bool TryGetRefStructIssueForIndexer(IPropertySymbol indexer, stri // stores a Func, 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; } diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs index b28165af7..fe6b0dd10 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs @@ -1767,7 +1767,9 @@ property.IndexerParameters is not null // Helpers.NeedsRefStructPipeline). A virtual class member still has a real implementation // behind it, so the accessor forwards to the wrapped instance or to base rather than // breaking a member that would otherwise work; interface and abstract members have nothing - // to forward to and throw. + // to forward to and throw. As on the method side, the forward honours none of the behaviour + // flags (see AppendUnsupportedRefStructReturnBody) because there is no setup to honour them + // against. bool isUnsupportedRefStructValue = property.Type.NeedsRefStructPipeline(); bool forwardsUnsupportedRefStructValue = isUnsupportedRefStructValue && !isClassInterface && property is { IsAbstract: false, IsStatic: false, }; @@ -2290,11 +2292,19 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb, (false, true) => "ref readonly ", (_, _) => "", }; - string refReturnStorageField = method.IsRefReturn || method.IsRefReadonlyReturn + // The shapes that degrade to a NotSupportedException stub below never touch the storage, and + // declaring it would break the whole mock over a member that is already given up on: a field + // of a non-span ref-struct type does not compile (CS8345). The `allows ref struct` clause + // guards the same hole (CS9244) and cannot be reached today only because such a method's + // verify surface is emitted unguarded and fails first. + bool hasRefReturnStorage = (method.IsRefReturn || method.IsRefReadonlyReturn) && + !method.ReturnType.NeedsRefStructPipeline() && + !method.HasUnsupportedAllowsRefStructTypeParameter; + string refReturnStorageField = hasRefReturnStorage ? "_refReturnStorage_" + new string((method.ContainingType + "_" + method.Name) .Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray()) : ""; - if (method.IsRefReturn || method.IsRefReadonlyReturn) + if (hasRefReturnStorage) { sb.Append("\t\tprivate ").Append(method.ReturnType.Fullname).Append(' ') .Append(refReturnStorageField).Append(';').AppendLine(); @@ -3127,8 +3137,11 @@ private static void AppendUnsupportedMethodThrow(StringBuilder sb, Method method /// abstract, static and ref-returning methods have nothing to forward to and throw. /// /// - /// The forward ignores MockBehavior.SkipBaseClass: without a setup there is no configured - /// value to return in its place, and a ref-struct return cannot be recorded either way. + /// The forward ignores the behaviour flags that a normal member honours, because there is no + /// setup to honour them against: MockBehavior.SkipBaseClass has no configured value to + /// return in the base call's place, MockBehavior.ThrowWhenNotSetup would reject a member + /// that can never be set up in the first place, and a ref-struct return cannot be recorded, so + /// SkipInteractionRecording is moot too. /// private static void AppendUnsupportedRefStructReturnBody(StringBuilder sb, Method method, string mockRegistry, string className, bool isClassInterface, bool explicitInterfaceImplementation) diff --git a/Source/Mockolate/Setup/ReadOnlySpanWrapper.cs b/Source/Mockolate/Setup/ReadOnlySpanWrapper.cs index 60f795846..ee9157a92 100644 --- a/Source/Mockolate/Setup/ReadOnlySpanWrapper.cs +++ b/Source/Mockolate/Setup/ReadOnlySpanWrapper.cs @@ -1,6 +1,8 @@ #if NET8_0_OR_GREATER using System; using System.Diagnostics; +using System.Linq; +using Mockolate.Internals; namespace Mockolate.Setup; @@ -39,5 +41,30 @@ public static implicit operator ReadOnlySpanWrapper(ReadOnlySpan span) { return new ReadOnlySpanWrapper(span); } + + /// + /// + /// Compares element-wise: a span-typed property or indexer + /// value is matched through EqualityComparer<ReadOnlySpanWrapper<T>>.Default, + /// so reference equality would make Set(someSpan) never match the recorded span. + /// + public override bool Equals(object? obj) + => obj is ReadOnlySpanWrapper other && ReadOnlySpanValues.SequenceEqual(other.ReadOnlySpanValues); + + /// + public override int GetHashCode() + { + HashCode hashCode = new(); + foreach (T value in ReadOnlySpanValues) + { + hashCode.Add(value); + } + + return hashCode.ToHashCode(); + } + + /// + public override string ToString() + => $"ReadOnlySpan<{typeof(T).FormatType()}>[{string.Join(", ", ReadOnlySpanValues)}]"; } #endif diff --git a/Source/Mockolate/Setup/SpanWrapper.cs b/Source/Mockolate/Setup/SpanWrapper.cs index 8b77adb05..da6b4f69e 100644 --- a/Source/Mockolate/Setup/SpanWrapper.cs +++ b/Source/Mockolate/Setup/SpanWrapper.cs @@ -1,6 +1,8 @@ #if NET8_0_OR_GREATER using System; using System.Diagnostics; +using System.Linq; +using Mockolate.Internals; namespace Mockolate.Setup; @@ -39,5 +41,30 @@ public static implicit operator SpanWrapper(Span span) { return new SpanWrapper(span); } + + /// + /// + /// Compares element-wise: a span-typed property or indexer value is + /// matched through EqualityComparer<SpanWrapper<T>>.Default, so reference + /// equality would make Set(someSpan) never match the recorded span. + /// + public override bool Equals(object? obj) + => obj is SpanWrapper other && SpanValues.SequenceEqual(other.SpanValues); + + /// + public override int GetHashCode() + { + HashCode hashCode = new(); + foreach (T value in SpanValues) + { + hashCode.Add(value); + } + + return hashCode.ToHashCode(); + } + + /// + public override string ToString() + => $"Span<{typeof(T).FormatType()}>[{string.Join(", ", SpanValues)}]"; } #endif diff --git a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs index 4b73f02d3..aa6b67fca 100644 --- a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs +++ b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs @@ -115,6 +115,214 @@ public void MyTest() """ ); + [Fact] + public async Task WhenMockingAbstractClassWithRefStructProperty_ShouldBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.AbstractPacketSource")}} + + namespace MyNamespace + { + public readonly ref struct Packet(int id) { public int Id { get; } = id; } + + public abstract class AbstractPacketSource + { + public abstract Packet Current { get; } + } + + public class MyClass + { + public void MyTest() + { + {|#0:AbstractPacketSource|}.CreateMock(); + } + } + } + """, + new DiagnosticResult("Mockolate0003", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("MyNamespace.AbstractPacketSource", "Current", + "properties of a non-span ref struct type are not supported") + ); + + [Fact] + public async Task WhenMockingClassWithVirtualInitOnlyRefStructProperty_ShouldBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.InitPacketSource")}} + + namespace MyNamespace + { + public readonly ref struct Packet(int id) { public int Id { get; } = id; } + + public class InitPacketSource + { + private int _id; + + // The getter would forward, but an init-only accessor cannot assign through the + // wrapped instance and keeps the NotSupportedException stub. + public virtual Packet Current + { + get => new Packet(_id); + init => _id = value.Id; + } + } + + public class MyClass + { + public void MyTest() + { + {|#0:InitPacketSource|}.CreateMock(); + } + } + } + """, + new DiagnosticResult("Mockolate0003", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("MyNamespace.InitPacketSource", "Current", + "properties of a non-span ref struct type are not supported") + ); + + [Fact] + public async Task WhenMockingClassWithVirtualRefReturningRefStructMethod_ShouldBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.PacketPicker")}} + + namespace MyNamespace + { + public readonly ref struct Packet(int id) { public int Id { get; } = id; } + + public class PacketPicker + { + // `return base.Pick(ref packet)` is not valid for a by-ref return, so this keeps + // the stub rather than forwarding. + public virtual ref Packet Pick(ref Packet packet) => ref packet; + } + + public class MyClass + { + public void MyTest() + { + {|#0:PacketPicker|}.CreateMock(); + } + } + } + """, + new DiagnosticResult("Mockolate0003", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("MyNamespace.PacketPicker", "Pick", + "methods returning a non-span ref struct are not supported") + ); + + [Fact] + public async Task WhenMockingClassWithVirtualRefStructIndexer_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.PacketCatalog")}} + + namespace MyNamespace + { + public readonly ref struct Packet(int id) { public int Id { get; } = id; } + + public class PacketCatalog + { + public virtual Packet this[int index] => new Packet(index); + } + + public class MyClass + { + public void MyTest() + { + PacketCatalog.CreateMock(); + } + } + } + """ + ); + + [Fact] + public async Task WhenMockingClassWithVirtualRefStructProperty_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.PacketSource")}} + + namespace MyNamespace + { + public readonly ref struct Packet(int id) { public int Id { get; } = id; } + + public class PacketSource + { + // No setup surface, but the override forwards to the wrapped instance or to base, + // so the mock keeps behaving like the real member and there is nothing to fix. + public virtual Packet Current => new Packet(11); + } + + public class MyClass + { + public void MyTest() + { + PacketSource.CreateMock(); + } + } + } + """ + ); + + [Fact] + public async Task WhenMockingClassWithVirtualRefStructReturnAndParameter_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.PacketTransformer")}} + + namespace MyNamespace + { + public readonly ref struct Packet(int id) { public int Id { get; } = id; } + + public class PacketTransformer + { + // The forwarding return branch wins over the parameter pipeline, so the + // ref-struct parameter must not be reported either. + public virtual Packet Transform(Packet packet) => packet; + } + + public class MyClass + { + public void MyTest() + { + PacketTransformer.CreateMock(); + } + } + } + """ + ); + + [Fact] + public async Task WhenMockingClassWithVirtualRefStructReturningMethod_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.PacketProducer")}} + + namespace MyNamespace + { + public readonly ref struct Packet(int id) { public int Id { get; } = id; } + + public class PacketProducer + { + public virtual Packet Produce() => new Packet(12); + } + + public class MyClass + { + public void MyTest() + { + PacketProducer.CreateMock(); + } + } + } + """ + ); + [Fact] public async Task WhenMockingDelegateReturningNonSpanRefStruct_ShouldBeFlagged() => await Verifier .VerifyAnalyzerAsync( diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt index 03ffc9227..e2be3c6a5 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net10.0.txt @@ -2775,6 +2775,9 @@ namespace Mockolate.Setup { public ReadOnlySpanWrapper(System.ReadOnlySpan span) { } public T[] ReadOnlySpanValues { get; } + public override bool Equals(object? obj) { } + public override int GetHashCode() { } + public override string ToString() { } public static System.ReadOnlySpan op_Implicit(Mockolate.Setup.ReadOnlySpanWrapper? wrapper) { } public static Mockolate.Setup.ReadOnlySpanWrapper op_Implicit(System.ReadOnlySpan span) { } } @@ -3132,6 +3135,9 @@ namespace Mockolate.Setup { public SpanWrapper(System.Span span) { } public T[] SpanValues { get; } + public override bool Equals(object? obj) { } + public override int GetHashCode() { } + public override string ToString() { } public static System.Span op_Implicit(Mockolate.Setup.SpanWrapper? wrapper) { } public static Mockolate.Setup.SpanWrapper op_Implicit(System.Span span) { } } diff --git a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt index 2ff0a62af..4c90f210c 100644 --- a/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt +++ b/Tests/Mockolate.Api.Tests/Expected/Mockolate_net8.0.txt @@ -2537,6 +2537,9 @@ namespace Mockolate.Setup { public ReadOnlySpanWrapper(System.ReadOnlySpan span) { } public T[] ReadOnlySpanValues { get; } + public override bool Equals(object? obj) { } + public override int GetHashCode() { } + public override string ToString() { } public static System.ReadOnlySpan op_Implicit(Mockolate.Setup.ReadOnlySpanWrapper? wrapper) { } public static Mockolate.Setup.ReadOnlySpanWrapper op_Implicit(System.ReadOnlySpan span) { } } @@ -2686,6 +2689,9 @@ namespace Mockolate.Setup { public SpanWrapper(System.Span span) { } public T[] SpanValues { get; } + public override bool Equals(object? obj) { } + public override int GetHashCode() { } + public override string ToString() { } public static System.Span op_Implicit(Mockolate.Setup.SpanWrapper? wrapper) { } public static Mockolate.Setup.SpanWrapper op_Implicit(System.Span span) { } } diff --git a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs index 21eb3de3d..5b878a753 100644 --- a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs +++ b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs @@ -33,6 +33,11 @@ public interface IPacketHolder Packet Current { get; } } + public interface IPacketSlot + { + Packet Current { get; set; } + } + public interface IPacketProducer { Packet Produce(); @@ -43,6 +48,11 @@ public interface IPacketCatalog Packet this[int index] { get; } } + public interface IPacketBin + { + Packet this[int index] { get; set; } + } + public sealed class EventTests { [Fact] @@ -97,6 +107,22 @@ public interface ISpanBuffer Span Buffer { get; set; } } + public class SpanHost + { + private byte[] _data = [1, 2,]; + + public virtual Span Buffer + { + get => _data.AsSpan(); + set => _data = value.ToArray(); + } + } + + public abstract class AbstractSpanHost + { + public abstract Span Buffer { get; set; } + } + [Fact] public async Task Getter_ShouldReturnConfiguredSpan() { @@ -111,13 +137,56 @@ await That(result).IsEqualTo(expected) } [Fact] - public async Task Setter_ShouldBeRecorded() + public async Task Setter_ShouldBeVerifiableByValue() { ISpanBuffer sut = ISpanBuffer.CreateMock(); sut.Buffer = new byte[] { 9, 8, }.AsSpan(); - await That(sut.Mock.Verify.Buffer.Set(It.IsAny>())).Once(); + await That(sut.Mock.Verify.Buffer.Set(new byte[] { 9, 8, }.AsSpan())).Once() + .Because("SpanWrapper compares its contents, so the raw-span overload matches"); + } + + [Fact] + public async Task Setter_WithNonMatchingValue_ShouldNotBeVerified() + { + ISpanBuffer sut = ISpanBuffer.CreateMock(); + + sut.Buffer = new byte[] { 9, 8, }.AsSpan(); + + await That(sut.Mock.Verify.Buffer.Set(new byte[] { 9, 7, }.AsSpan())).Never(); + } + + [Fact] + public async Task Setter_ShouldBeVerifiableByPredicate() + { + ISpanBuffer sut = ISpanBuffer.CreateMock(); + + sut.Buffer = new byte[] { 9, 8, }.AsSpan(); + + await That(sut.Mock.Verify.Buffer.Set(It.IsSpan(values => values.Length == 2))).Once(); + } + + [Fact] + public async Task OnVirtualClassMember_ShouldRoundTripThroughBase() + { + SpanHost sut = SpanHost.CreateMock(); + + byte[] initial = sut.Buffer.ToArray(); + sut.Buffer = new byte[] { 7, }.AsSpan(); + + await That(initial).IsEqualTo(new byte[] { 1, 2, }) + .Because("without a setup the getter falls back to the base implementation"); + await That(sut.Buffer.ToArray()).IsEqualTo(new byte[] { 7, }); + } + + [Fact] + public async Task OnAbstractClassMember_ShouldReturnConfiguredSpan() + { + AbstractSpanHost sut = AbstractSpanHost.CreateMock(); + sut.Mock.Setup.Buffer.InitializeWith(new byte[] { 3, 4, }.AsSpan()); + + await That(sut.Buffer.ToArray()).IsEqualTo(new byte[] { 3, 4, }); } } @@ -138,6 +207,17 @@ public interface ISpanBufferStore Span this[int index] { get; set; } } + public class SpanHost + { + private byte[] _data = [1, 2,]; + + public virtual Span this[int index] + { + get => _data.AsSpan(index); + set => _data = value.ToArray(); + } + } + [Fact] public async Task GetterOnly_ShouldReturnConfiguredSpan() { @@ -152,44 +232,100 @@ await That(result).IsEqualTo(expected) } [Fact] - public async Task SetterOnly_ShouldBeRecorded() + public async Task SetterOnly_ShouldBeVerifiableByValue() { ISpanBufferSetter sut = ISpanBufferSetter.CreateMock(); sut[4] = new byte[] { 9, 8, }.AsSpan(); - await That(sut.Mock.Verify[4].Set(It.IsAny>())).Once() + await That(sut.Mock.Verify[4].Set(new byte[] { 9, 8, }.AsSpan())).Once() .Because("the setter dispatches through ApplyIndexerSetter>"); } [Fact] - public async Task GetAndSet_ShouldBeRecorded() + public async Task GetAndSet_ShouldBeVerifiableByValue() { ISpanBufferStore sut = ISpanBufferStore.CreateMock(); sut[7] = new byte[] { 5, }.AsSpan(); - await That(sut.Mock.Verify[7].Set(It.IsAny>())).Once(); + await That(sut.Mock.Verify[7].Set(new byte[] { 5, }.AsSpan())).Once(); + } + + [Fact] + public async Task OnVirtualClassMember_ShouldRoundTripThroughBase() + { + SpanHost sut = SpanHost.CreateMock(); + + byte[] initial = sut[0].ToArray(); + sut[0] = new byte[] { 7, }.AsSpan(); + + await That(initial).IsEqualTo(new byte[] { 1, 2, }) + .Because("without a setup the getter falls back to the base implementation"); + await That(sut[0].ToArray()).IsEqualTo(new byte[] { 7, }); } } #endif public class PacketSource { + private int _slotId = 1; + public virtual Packet Current => new(11, []); + public virtual Packet Slot + { + get => new(_slotId, []); + set => _slotId = value.Id; + } + + public virtual Packet this[int index] + { + get => new(_slotId + index, []); + set => _slotId = value.Id; + } + public virtual Packet Produce() => new(12, []); } - public abstract class AbstractPacketSource + public class ProtectedPacketSource { - public abstract Packet Current { get; } + private int _id = 3; + + protected virtual Packet Current + { + get => new(_id, []); + set => _id = value.Id; + } + + protected virtual Packet Produce() => new(_id + 100, []); + + public int ExerciseProtectedMembers() + { + Current = new Packet(9, []); + return Current.Id + Produce().Id; + } } -#pragma warning disable Mockolate0003 // Ref-struct usage is not supported on this compilation + // Gated because an `init` accessor needs IsExternalInit, which .NET Framework does not ship and + // this project does not polyfill - both this declaration and the generated override need it. +#if NET8_0_OR_GREATER + public class InitOnlyPacketSource + { + private readonly int _id = 4; + + public virtual Packet Current + { + get => new(_id, []); + init => _id = value.Id; + } + } +#endif + /// /// A ref struct in a value position has no setup surface, but a virtual class member still - /// has a real implementation behind it and keeps working. + /// has a real implementation behind it and keeps working. No Mockolate0003 suppression is + /// needed here: the analyzer does not flag members that forward. /// public sealed class ClassPassthroughTests { @@ -222,6 +358,28 @@ await That(id).IsEqualTo(12) .Because("a virtual member without a setup surface forwards to base instead of throwing"); } + [Fact] + public async Task VirtualPropertySetter_ShouldFallBackToBase() + { + PacketSource sut = PacketSource.CreateMock(); + + sut.Slot = new Packet(7, []); + + await That(sut.Slot.Id).IsEqualTo(7) + .Because("the setter forwards to base, so the base getter observes the written value"); + } + + [Fact] + public async Task VirtualIndexer_ShouldFallBackToBase() + { + PacketSource sut = PacketSource.CreateMock(); + + sut[0] = new Packet(8, []); + + await That(sut[0].Id).IsEqualTo(8) + .Because("both indexer accessors forward to base"); + } + [Fact] public async Task VirtualProperty_WhenWrapping_ShouldUseWrappedInstance() { @@ -245,20 +403,99 @@ await That(id).IsEqualTo(98) } [Fact] - public async Task AbstractProperty_ShouldThrowNotSupported() + public async Task VirtualPropertySetter_WhenWrapping_ShouldUseWrappedInstance() { - AbstractPacketSource sut = AbstractPacketSource.CreateMock(); + LoudPacketSource wrapped = new(); + PacketSource sut = PacketSource.CreateMock().Wrapping(wrapped); - void Act() => _ = sut.Current; + sut.Slot = new Packet(5, []); - await That(Act).Throws() - .WithMessage("*properties of a non-span ref struct type are not supported*").AsWildcard() - .Because("an abstract member has no implementation to forward to"); + await That(wrapped.Slot.Id).IsEqualTo(5) + .Because("the setter writes to the wrapped instance rather than to base"); + } + + [Fact] + public async Task VirtualIndexerSetter_WhenWrapping_ShouldUseWrappedInstance() + { + LoudPacketSource wrapped = new(); + PacketSource sut = PacketSource.CreateMock().Wrapping(wrapped); + + sut[0] = new Packet(6, []); + + await That(wrapped[0].Id).IsEqualTo(6) + .Because("the indexer setter writes to the wrapped instance rather than to base"); + } + + [Fact] + public async Task ProtectedMembers_ShouldFallBackToBase() + { + ProtectedPacketSource sut = ProtectedPacketSource.CreateMock(); + + int result = sut.ExerciseProtectedMembers(); + + await That(result).IsEqualTo(9 + 109) + .Because("a protected member cannot be reached through Wraps and forwards to base only"); + } + +#if NET8_0_OR_GREATER + // Suppressed rather than unflagged: the init accessor keeps the throwing stub, so the member + // is only half-degraded and the analyzer is right to report it. +#pragma warning disable Mockolate0003 // Ref-struct usage is not supported on this compilation + [Fact] + public async Task InitOnlyProperty_ShouldFallBackToBase() + { + InitOnlyPacketSource sut = InitOnlyPacketSource.CreateMock(); + + int id = sut.Current.Id; + + await That(id).IsEqualTo(4) + .Because("the getter forwards even though the init accessor keeps the throwing stub"); + } +#pragma warning restore Mockolate0003 +#endif + + [Fact] + public async Task VirtualProperty_WithStrictBehavior_ShouldStillFallBackToBase() + { + PacketSource sut = PacketSource.CreateMock(MockBehavior.Default with + { + ThrowWhenNotSetup = true, + }); + + int id = sut.Current.Id; + + await That(id).IsEqualTo(11) + .Because("ThrowWhenNotSetup would reject a member that can never be set up"); + } + + [Fact] + public async Task VirtualProperty_WithSkipBaseClass_ShouldStillFallBackToBase() + { + PacketSource sut = PacketSource.CreateMock(MockBehavior.Default with + { + SkipBaseClass = true, + }); + + int id = sut.Current.Id; + + await That(id).IsEqualTo(11) + .Because("SkipBaseClass has no configured value to return in the base call's place"); } } +#pragma warning disable Mockolate0003 // Ref-struct usage is not supported on this compilation public sealed class UnsupportedValuePositionTests { + public abstract class AbstractPacketSource + { + public abstract Packet Current { get; } + } + + public class PacketPicker + { + public virtual ref Packet Pick(ref Packet packet) => ref packet; + } + [Fact] public async Task RefStructProperty_ShouldThrowNotSupported() { @@ -270,6 +507,29 @@ await That(Act).Throws() .WithMessage("*properties of a non-span ref struct type are not supported*").AsWildcard(); } + [Fact] + public async Task RefStructPropertySetter_ShouldThrowNotSupported() + { + IPacketSlot sut = IPacketSlot.CreateMock(); + + void Act() => sut.Current = new Packet(5, []); + + await That(Act).Throws() + .WithMessage("*properties of a non-span ref struct type are not supported*").AsWildcard(); + } + + [Fact] + public async Task AbstractRefStructProperty_ShouldThrowNotSupported() + { + AbstractPacketSource sut = AbstractPacketSource.CreateMock(); + + void Act() => _ = sut.Current; + + await That(Act).Throws() + .WithMessage("*properties of a non-span ref struct type are not supported*").AsWildcard() + .Because("an abstract member has no implementation to forward to"); + } + [Fact] public async Task RefStructReturningMethod_ShouldThrowNotSupported() { @@ -281,6 +541,22 @@ await That(Act).Throws() .WithMessage("*methods returning a non-span ref struct are not supported*").AsWildcard(); } + [Fact] + public async Task RefReturningRefStructMethod_ShouldThrowNotSupported() + { + PacketPicker sut = PacketPicker.CreateMock(); + + void Act() + { + Packet packet = new(1, []); + _ = sut.Pick(ref packet); + } + + await That(Act).Throws() + .WithMessage("*methods returning a non-span ref struct are not supported*").AsWildcard() + .Because("`return base.Pick(ref packet)` is not valid for a by-ref return"); + } + [Fact] public async Task RefStructIndexer_ShouldThrowNotSupported() { @@ -292,6 +568,17 @@ await That(Act).Throws() .WithMessage("*indexers returning a non-span ref struct are not supported*").AsWildcard(); } + [Fact] + public async Task RefStructIndexerSetter_ShouldThrowNotSupported() + { + IPacketBin sut = IPacketBin.CreateMock(); + + void Act() => sut[0] = new Packet(5, []); + + await That(Act).Throws() + .WithMessage("*indexers returning a non-span ref struct are not supported*").AsWildcard(); + } + [Fact] public async Task RefStructReturningDelegate_ShouldThrowNotSupported() { diff --git a/Tests/Mockolate.Tests/Setup/ReadOnlySpanWrapperTests.cs b/Tests/Mockolate.Tests/Setup/ReadOnlySpanWrapperTests.cs new file mode 100644 index 000000000..6fab7cc7a --- /dev/null +++ b/Tests/Mockolate.Tests/Setup/ReadOnlySpanWrapperTests.cs @@ -0,0 +1,78 @@ +#if NET8_0_OR_GREATER +using System; +using Mockolate.Setup; + +namespace Mockolate.Tests.Setup; + +public class ReadOnlySpanWrapperTests +{ + [Fact] + public async Task Equals_WithDifferentLength_ShouldReturnFalse() + { + ReadOnlySpanWrapper sut = new([1, 2,]); + + await That(sut.Equals(new ReadOnlySpanWrapper([1,]))).IsFalse() + .Because("a shorter span is not the same span"); + } + + [Fact] + public async Task Equals_WithDifferentValues_ShouldReturnFalse() + { + ReadOnlySpanWrapper sut = new([1, 2,]); + + await That(sut.Equals(new ReadOnlySpanWrapper([1, 3,]))).IsFalse(); + } + + [Fact] + public async Task Equals_WithEqualValues_ShouldReturnTrue() + { + ReadOnlySpanWrapper sut = new([1, 2,]); + + await That(sut.Equals(new ReadOnlySpanWrapper([1, 2,]))).IsTrue() + .Because("equality is content-based, so a re-wrapped span with the same values matches"); + } + + [Fact] + public async Task Equals_WithNull_ShouldReturnFalse() + { + ReadOnlySpanWrapper sut = new([1,]); + + await That(sut.Equals(null)).IsFalse(); + } + + [Fact] + public async Task Equals_WithSpanWrapper_ShouldReturnFalse() + { + ReadOnlySpanWrapper sut = new([1,]); + + await That(sut.Equals(new SpanWrapper([1,]))).IsFalse() + .Because("the two wrappers represent different parameter shapes"); + } + + [Fact] + public async Task GetHashCode_WithEqualValues_ShouldMatch() + { + ReadOnlySpanWrapper sut = new([1, 2,]); + + await That(sut.GetHashCode()).IsEqualTo(new ReadOnlySpanWrapper([1, 2,]).GetHashCode()); + } + + [Fact] + public async Task GetHashCode_WithNullElement_ShouldNotThrow() + { + ReadOnlySpanWrapper sut = new([null, "a",]); + + await That(sut.GetHashCode).DoesNotThrow() + .Because("a reference-typed span may carry null elements"); + } + + [Fact] + public async Task ToString_ShouldRenderElementTypeAndValues() + { + ReadOnlySpanWrapper sut = new([1, 2,]); + + await That(sut.ToString()).IsEqualTo("ReadOnlySpan[1, 2]") + .Because("recorded interactions render the wrapper, not the underlying span"); + } +} +#endif diff --git a/Tests/Mockolate.Tests/Setup/SpanWrapperTests.cs b/Tests/Mockolate.Tests/Setup/SpanWrapperTests.cs new file mode 100644 index 000000000..b876d2747 --- /dev/null +++ b/Tests/Mockolate.Tests/Setup/SpanWrapperTests.cs @@ -0,0 +1,78 @@ +#if NET8_0_OR_GREATER +using System; +using Mockolate.Setup; + +namespace Mockolate.Tests.Setup; + +public class SpanWrapperTests +{ + [Fact] + public async Task Equals_WithDifferentLength_ShouldReturnFalse() + { + SpanWrapper sut = new([1, 2,]); + + await That(sut.Equals(new SpanWrapper([1,]))).IsFalse() + .Because("a shorter span is not the same span"); + } + + [Fact] + public async Task Equals_WithDifferentValues_ShouldReturnFalse() + { + SpanWrapper sut = new([1, 2,]); + + await That(sut.Equals(new SpanWrapper([1, 3,]))).IsFalse(); + } + + [Fact] + public async Task Equals_WithEqualValues_ShouldReturnTrue() + { + SpanWrapper sut = new([1, 2,]); + + await That(sut.Equals(new SpanWrapper([1, 2,]))).IsTrue() + .Because("equality is content-based, so a re-wrapped span with the same values matches"); + } + + [Fact] + public async Task Equals_WithNull_ShouldReturnFalse() + { + SpanWrapper sut = new([1,]); + + await That(sut.Equals(null)).IsFalse(); + } + + [Fact] + public async Task Equals_WithReadOnlySpanWrapper_ShouldReturnFalse() + { + SpanWrapper sut = new([1,]); + + await That(sut.Equals(new ReadOnlySpanWrapper([1,]))).IsFalse() + .Because("the two wrappers represent different parameter shapes"); + } + + [Fact] + public async Task GetHashCode_WithEqualValues_ShouldMatch() + { + SpanWrapper sut = new([1, 2,]); + + await That(sut.GetHashCode()).IsEqualTo(new SpanWrapper([1, 2,]).GetHashCode()); + } + + [Fact] + public async Task GetHashCode_WithNullElement_ShouldNotThrow() + { + SpanWrapper sut = new([null, "a",]); + + await That(sut.GetHashCode).DoesNotThrow() + .Because("a reference-typed span may carry null elements"); + } + + [Fact] + public async Task ToString_ShouldRenderElementTypeAndValues() + { + SpanWrapper sut = new([1, 2,]); + + await That(sut.ToString()).IsEqualTo("Span[1, 2]") + .Because("recorded interactions render the wrapper, not the underlying span"); + } +} +#endif From ca3899024445636f07106ba2f2365ac3b9b98f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Wed, 16 Sep 2026 06:29:35 +0200 Subject: [PATCH 7/8] fix: flag ref-struct parameters the analyzer was blind to The analyzer only ever modelled the type-level half of the generator's ref-struct rule, so `ref readonly Span` 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. --- Docs/pages/08-analyzers.md | 13 ++- Docs/pages/setup/04-parameter-matching.md | 4 +- .../MockabilityAnalyzer.cs | 17 +++- .../Entities/Method.cs | 7 ++ .../MockabilityAnalyzerRefStructTests.cs | 85 +++++++++++++++++++ .../RefStruct/RefStructValuePositionTests.cs | 21 +++++ 6 files changed, 140 insertions(+), 7 deletions(-) diff --git a/Docs/pages/08-analyzers.md b/Docs/pages/08-analyzers.md index a2b1eddcb..10bcf4946 100644 --- a/Docs/pages/08-analyzers.md +++ b/Docs/pages/08-analyzers.md @@ -108,7 +108,9 @@ Also never supported: - Ref-struct **parameters on delegate types**. A delegate mock projects its single `Invoke` onto `VoidMethodSetup` / `ReturnMethodSetup`, neither of which carries the anti-constraint the - interface and class pipelines get from `RefStructVoidMethodSetup`. + interface and class pipelines get from `RefStructVoidMethodSetup`. This includes + `ref readonly Span` / `ref readonly ReadOnlySpan`, 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. @@ -126,9 +128,12 @@ What the member does at runtime depends on whether there is an implementation be **Note:** `Span` and `ReadOnlySpan` flow through the existing `SpanWrapper` / `ReadOnlySpanWrapper` -fallback and are never flagged - in parameter *and* value positions. 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. +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. diff --git a/Docs/pages/setup/04-parameter-matching.md b/Docs/pages/setup/04-parameter-matching.md index ddf9b7c3d..6544555ce 100644 --- a/Docs/pages/setup/04-parameter-matching.md +++ b/Docs/pages/setup/04-parameter-matching.md @@ -273,7 +273,9 @@ The following cases are flagged with diagnostic `Mockolate0003`: `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` / `ReadOnlySpan` are supported in those positions.) -- Ref-struct parameters on delegate types. +- Ref-struct parameters on delegate types, `ref readonly Span` / `ref readonly ReadOnlySpan` + 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 diff --git a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs index f43d5afae..09ba2aaed 100644 --- a/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs +++ b/Source/Mockolate.Analyzers/MockabilityAnalyzer.cs @@ -275,6 +275,19 @@ private static bool NeedsRefStructPipeline(ITypeSymbol type) type.Name is "Span" or "ReadOnlySpan"); } + /// + /// Mirrors the MethodParameter overload of Helpers.NeedsRefStructPipeline: on top of + /// the type-level rule, a ref readonly Span<T>/ReadOnlySpan<T> 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 : 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. + /// + private static bool ParameterNeedsRefStructPipeline(IParameterSymbol parameter) + => NeedsRefStructPipeline(parameter.Type) || + (parameter.RefKind == RefKind.RefReadOnlyParameter && parameter.Type.IsRefLikeType); + /// /// Whether the generator degrades to a passthrough rather than a /// NotSupportedException stub, in which case the mock keeps behaving like the real member @@ -352,7 +365,7 @@ private static bool TryGetRefStructIssue(IMethodSymbol method, string? pipelineU bool hasRefStructParam = false; foreach (IParameterSymbol p in method.Parameters) { - if (!NeedsRefStructPipeline(p.Type)) + if (!ParameterNeedsRefStructPipeline(p)) { continue; } @@ -403,7 +416,7 @@ private static bool TryGetRefStructIssueForIndexer(IPropertySymbol indexer, stri return true; } - if (!indexer.Parameters.Any(p => NeedsRefStructPipeline(p.Type))) + if (!indexer.Parameters.Any(ParameterNeedsRefStructPipeline)) { issue = null; return false; diff --git a/Source/Mockolate.SourceGenerators/Entities/Method.cs b/Source/Mockolate.SourceGenerators/Entities/Method.cs index 3bb42d205..da1b88bb3 100644 --- a/Source/Mockolate.SourceGenerators/Entities/Method.cs +++ b/Source/Mockolate.SourceGenerators/Entities/Method.cs @@ -74,6 +74,13 @@ public Method(IMethodSymbol methodSymbol, List? alreadyDefinedMethods, I /// and no setup/verify surface; Span<T>/ReadOnlySpan<T> parameters are /// exempt because they flow through their wrappers. /// + /// + /// Deliberately the MethodParameter overload of NeedsRefStructPipeline, so + /// ref readonly Span/ReadOnlySpan is caught too: that ref kind has no wrapper-based emit + /// branch, so AppendMethodSetupDefinition would hand it the .NET 9-gated + /// IRefStruct*Setup surface while the delegate's Invoke body dispatches through + /// VoidMethodSetup<SpanWrapper<T>> - a setup the mock could never match. + /// public bool IsDelegateWithUnsupportedRefStructParameter => IsDelegateInvoke && Parameters.Any(parameter => parameter.NeedsRefStructPipeline()); diff --git a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs index aa6b67fca..2d748f77f 100644 --- a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs +++ b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs @@ -21,6 +21,38 @@ namespace Mockolate.Analyzers.Tests; /// public class MockabilityAnalyzerRefStructTests { + [Fact] + public async Task WhenLanguageVersionBelowCSharp13_RefReadonlySpanParameterMethod_ShouldBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.ISpanInspector")}} + + namespace MyNamespace + { + public interface ISpanInspector + { + // `ref readonly` Span has no wrapper-based emit branch on the interface pipeline and + // falls back to the generic ref-struct path, so it needs .NET 9 / C# 13 even though + // the same parameter by value would not. + void Inspect(ref readonly System.Span values); + } + + public class MyClass + { + public void MyTest() + { + {|#0:ISpanInspector|}.CreateMock(); + } + } + } + """, + LanguageVersion.CSharp12, + new DiagnosticResult("Mockolate0003", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("MyNamespace.ISpanInspector", "Inspect", + "ref-struct parameter mocking requires C# 13 or later (uses the 'allows ref struct' anti-constraint; current LangVersion is 12.0)") + ); + [Fact] public async Task WhenLanguageVersionBelowCSharp13_RefStructKeyedIndexer_ShouldBeFlagged() => await Verifier .VerifyAnalyzerAsync( @@ -412,6 +444,33 @@ public void MyTest() "ref-struct parameters are not supported on delegate types") ); + [Fact] + public async Task WhenMockingDelegateWithRefReadonlySpanParameter_ShouldBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.SpanInspector")}} + + namespace MyNamespace + { + // `ref readonly` Span is the one span ref kind with no wrapper-based emit branch, so it + // reaches the delegate carve-out even though the same parameter by value would not. + public delegate void SpanInspector(ref readonly System.Span values); + + public class MyClass + { + public void MyTest() + { + {|#0:SpanInspector|}.CreateMock(); + } + } + } + """, + new DiagnosticResult("Mockolate0003", DiagnosticSeverity.Warning) + .WithLocation(0) + .WithArguments("MyNamespace.SpanInspector", "Invoke", + "ref-struct parameters are not supported on delegate types") + ); + [Fact] public async Task WhenMockingInterfaceInheritingRefStructOutMethod_ShouldNotBeFlagged() => await Verifier .VerifyAnalyzerAsync( @@ -580,6 +639,32 @@ public void MyTest() """ ); + [Fact] + public async Task WhenMockingInterfaceWithRefReadonlySpanParameter_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.ISpanInspector")}} + + namespace MyNamespace + { + public interface ISpanInspector + { + // Routes through the ref-struct pipeline, which this compilation hosts — so the + // parameter-level rule must not report on a supported target either. + void Inspect(ref readonly System.Span values); + } + + public class MyClass + { + public void MyTest() + { + ISpanInspector.CreateMock(); + } + } + } + """ + ); + [Fact] public async Task WhenMockingInterfaceWithRefRefStructParameter_ShouldNotBeFlagged() => await Verifier .VerifyAnalyzerAsync( diff --git a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs index 5b878a753..0fd2118f7 100644 --- a/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs +++ b/Tests/Mockolate.Tests/RefStruct/RefStructValuePositionTests.cs @@ -600,6 +600,27 @@ public async Task RefStructParameterDelegate_ShouldThrowNotSupported() await That(Act).Throws() .WithMessage("*ref-struct parameters are not supported on delegate types*").AsWildcard(); } + +#if NET8_0_OR_GREATER + public delegate void SpanInspector(ref readonly Span values); + + [Fact] + public async Task RefReadonlySpanParameterDelegate_ShouldThrowNotSupported() + { + SpanInspector sut = SpanInspector.CreateMock(); + + void Act() + { + Span values = new([1, 2,]); + sut(in values); + } + + await That(Act).Throws() + .WithMessage("*ref-struct parameters are not supported on delegate types*").AsWildcard() + .Because( + "`ref readonly` Span has no wrapper-based emit branch, so its setup surface would be the .NET 9-gated ref-struct one that a delegate's Invoke never dispatches through"); + } +#endif } #pragma warning restore Mockolate0003 } From 0f1609861eb17c98b1e0467b6df4b4a30251a48e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Wed, 16 Sep 2026 07:52:57 +0200 Subject: [PATCH 8/8] test: pin the delegate carve-out to the wrapper boundary 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. --- .../Sources/Sources.MockClass.cs | 13 ++--- .../MockabilityAnalyzerRefStructTests.cs | 23 +++++++++ .../MockTests.DelegateTests.cs | 33 +++++++++++- .../MockDelegates/DelegateTests.SpanTests.cs | 51 +++++++++++++++++++ .../MockDelegates/DelegateTests.cs | 2 +- 5 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 Tests/Mockolate.Tests/MockDelegates/DelegateTests.SpanTests.cs diff --git a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs index fe6b0dd10..bd1909dde 100644 --- a/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs +++ b/Source/Mockolate.SourceGenerators/Sources/Sources.MockClass.cs @@ -2815,12 +2815,13 @@ private static void AppendMockSubject_ImplementClass_AddMethod(StringBuilder sb, /// feature and the ref-struct setup types only compile on net9.0+. /// /// - /// Out of scope (throws NotSupportedException at mock-invocation time, but the - /// mock class still compiles so the rest of the interface can be mocked): - /// arity above 4; out/ref ref-struct parameters. A non-span ref-struct return never reaches - /// here - the caller stubs it out first, so it degrades on every target instead of hitting - /// the #error above. Proper analyzer-level diagnostics for these live in - /// MockabilityAnalyzer. + /// Every ref kind is in scope: by value, out, ref and ref readonly, the + /// by-ref ones writing back through IOutRefStructParameter / + /// IRefRefStructParameter below. There is no arity ceiling either - arities 1-4 are + /// hand-written in Source/Mockolate/Setup/, 5+ are generator-emitted into + /// RefStructMethodSetups.g.cs. A non-span ref-struct return never reaches here: the + /// caller stubs it out first, so it degrades on every target instead of hitting the + /// #error above. Analyzer-level diagnostics live in MockabilityAnalyzer. /// /// private static void AppendMockSubject_ImplementClass_AddRefStructMethodBody( diff --git a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs index 2d748f77f..b319b66fe 100644 --- a/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs +++ b/Tests/Mockolate.Analyzers.Tests/MockabilityAnalyzerRefStructTests.cs @@ -471,6 +471,29 @@ public void MyTest() "ref-struct parameters are not supported on delegate types") ); + [Fact] + public async Task WhenMockingDelegateWithSpanParameter_ShouldNotBeFlagged() => await Verifier + .VerifyAnalyzerAsync( + $$""" + {{GeneratedPrefix("MyNamespace.SpanConsumer")}} + + namespace MyNamespace + { + // The delegate carve-out stops at the wrapper: by value, Span/ReadOnlySpan flow through + // SpanWrapper/ReadOnlySpanWrapper, so the delegate keeps its full setup/verify surface. + public delegate void SpanConsumer(System.Span buffer, System.ReadOnlySpan text); + + public class MyClass + { + public void MyTest() + { + SpanConsumer.CreateMock(); + } + } + } + """ + ); + [Fact] public async Task WhenMockingInterfaceInheritingRefStructOutMethod_ShouldNotBeFlagged() => await Verifier .VerifyAnalyzerAsync( diff --git a/Tests/Mockolate.SourceGenerators.Tests/MockTests.DelegateTests.cs b/Tests/Mockolate.SourceGenerators.Tests/MockTests.DelegateTests.cs index dabc1bc4d..331063886 100644 --- a/Tests/Mockolate.SourceGenerators.Tests/MockTests.DelegateTests.cs +++ b/Tests/Mockolate.SourceGenerators.Tests/MockTests.DelegateTests.cs @@ -52,7 +52,7 @@ await That(result.Sources["Mock.Program_DoSomething.g.cs"]) } [Fact] - public async Task CustomDelegates_ShouldSupportSpanAndReadOnlySpanParameters() + public async Task CustomDelegates_ShouldSupportSpanAndReadOnlySpanReturnTypes() { GeneratorResult result = Generator .Run(""" @@ -124,6 +124,37 @@ await That(result.Sources["Mock.Program_DoSomething2.g.cs"]) """).IgnoringNewlineStyle(); } + [Fact] + public async Task CustomDelegates_WithSpanParameter_ShouldUseWrapperInsteadOfDegrading() + { + GeneratorResult result = Generator + .Run(""" + using System; + using Mockolate; + + namespace MyCode; + + public class Program + { + public static void Main(string[] args) + { + _ = DoSomething1.CreateMock(); + } + + public delegate int DoSomething1(Span buffer); + } + """); + + await That(result.Sources).ContainsKey("Mock.Program_DoSomething1.g.cs"); + await That(result.Sources["Mock.Program_DoSomething1.g.cs"]) + .Contains( + "global::Mockolate.Setup.ReturnMethodSetup>") + .IgnoringNewlineStyle().And + .DoesNotContain("NotSupportedException") + .Because( + "a by-value Span parameter stays outside the delegate ref-struct carve-out, so the setup surface is emitted against SpanWrapper"); + } + [Fact] public async Task CustomVoidDelegates_ShouldOnlyCreateTwoExtensionsForSetupAndVerify() { diff --git a/Tests/Mockolate.Tests/MockDelegates/DelegateTests.SpanTests.cs b/Tests/Mockolate.Tests/MockDelegates/DelegateTests.SpanTests.cs new file mode 100644 index 000000000..cbfca682f --- /dev/null +++ b/Tests/Mockolate.Tests/MockDelegates/DelegateTests.SpanTests.cs @@ -0,0 +1,51 @@ +#if NET8_0_OR_GREATER + +namespace Mockolate.Tests.MockDelegates; + +public partial class DelegateTests +{ + public sealed class SpanTests + { + [Fact] + public async Task ReadOnlySpanParameter_ShouldSupportSetup() + { + ReadOnlySpanConsumer sut = ReadOnlySpanConsumer.CreateMock(); + sut.Mock.Setup(It.IsReadOnlySpan(values => values.Length == 3)).Returns(42); + + int result = sut(new ReadOnlySpan(['a', 'b', 'c',])); + + await That(result).IsEqualTo(42) + .Because( + "a by-value ReadOnlySpan parameter flows through ReadOnlySpanWrapper, so the delegate keeps its setup surface instead of degrading to a NotSupportedException stub"); + } + + [Fact] + public async Task SpanParameter_ShouldSupportSetup() + { + SpanConsumer sut = SpanConsumer.CreateMock(); + sut.Mock.Setup(It.IsSpan(values => values.Length == 2)).Returns(42); + + int result = sut(new Span([1, 2,])); + + await That(result).IsEqualTo(42) + .Because( + "a by-value Span parameter flows through SpanWrapper, so the delegate keeps its setup surface instead of degrading to a NotSupportedException stub"); + } + + [Fact] + public async Task SpanParameter_ShouldSupportVerify() + { + SpanConsumer sut = SpanConsumer.CreateMock(); + + _ = sut(new Span([1, 2,])); + + await That(sut.Mock.Verify(It.IsSpan(values => values.Length == 2))).Once() + .Because("the invocation is recorded rather than stubbed out"); + } + + internal delegate int ReadOnlySpanConsumer(ReadOnlySpan text); + + internal delegate int SpanConsumer(Span buffer); + } +} +#endif diff --git a/Tests/Mockolate.Tests/MockDelegates/DelegateTests.cs b/Tests/Mockolate.Tests/MockDelegates/DelegateTests.cs index c92b3d60d..d71180393 100644 --- a/Tests/Mockolate.Tests/MockDelegates/DelegateTests.cs +++ b/Tests/Mockolate.Tests/MockDelegates/DelegateTests.cs @@ -2,7 +2,7 @@ namespace Mockolate.Tests.MockDelegates; -public class DelegateTests +public partial class DelegateTests { [Fact] public async Task Action_ShouldBeTreatedAsVoidDelegate()