Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/TUnit.Mocks.SourceGenerator/Builders/MockImplBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1208,7 +1208,8 @@ private static void GenerateEvent(CodeWriter writer, MockEventModel evt)
? ""
: string.Join(", ", evt.RaiseParameterList.Select(p => $"{p.FullyQualifiedType} {p.Name}"));
var invokeArgs = string.IsNullOrEmpty(evt.InvokeArgs) ? "" : evt.InvokeArgs;
using (writer.Block($"internal void Raise_{evt.Name}({raiseParams})"))
var raiseAccessModifier = evt.IsSignatureAccessibleFromAssembly ? "internal" : "private";
using (writer.Block($"{raiseAccessModifier} void Raise_{evt.Name}({raiseParams})"))
{
if (string.IsNullOrEmpty(invokeArgs))
{
Expand Down Expand Up @@ -1242,7 +1243,8 @@ private static void GeneratePartialEvent(CodeWriter writer, MockEventModel evt)
? ""
: string.Join(", ", evt.RaiseParameterList.Select(p => $"{p.FullyQualifiedType} {p.Name}"));
var invokeArgs = string.IsNullOrEmpty(evt.InvokeArgs) ? "" : evt.InvokeArgs;
using (writer.Block($"internal void Raise_{evt.Name}({raiseParams})"))
var raiseAccessModifier = evt.IsSignatureAccessibleFromAssembly ? "internal" : "private";
using (writer.Block($"{raiseAccessModifier} void Raise_{evt.Name}({raiseParams})"))
{
if (string.IsNullOrEmpty(invokeArgs))
{
Expand Down Expand Up @@ -1589,9 +1591,13 @@ private static void EmitOutRefParamAssignments(CodeWriter writer, MockMemberMode
/// struct out/ref params. Generic mock types and generic methods are excluded — their
/// param types may reference type parameters that aren't fully bound at delegate-decl
/// time and would require an <c>allows ref struct</c> constraint (C# 13, net9.0+ runtime).
/// Methods absent from the setup surface cannot register a setter and must not reference its
/// otherwise-unemitted delegate from the implementation.
/// </summary>
internal static bool SupportsClosedRefStructSetter(MockTypeModel model, MockMemberModel method)
=> !method.IsGenericMethod && model.TypeParameters.Length == 0;
=> method.IsSignatureAccessibleFromAssembly
&& !method.IsGenericMethod
&& model.TypeParameters.Length == 0;

internal static string EmitArgsArrayVariable(CodeWriter writer, MockMemberModel method)
{
Expand Down
37 changes: 23 additions & 14 deletions src/TUnit.Mocks.SourceGenerator/Builders/MockMembersBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public static string Build(MockTypeModel model)
// name appends the interface; for everything else it equals GetSafeName(FullyQualifiedName).
var safeName = MockImplBuilder.GetCompositeSafeName(model);
var mockableType = MockImplBuilder.GetMockableTypeName(model);
var instanceEvents = model.Events.Where(e => !e.IsStaticAbstract).ToArray();
var instanceEvents = model.Events.Where(IsConfigurableEvent).ToArray();
var hasEvents = instanceEvents.Length > 0;
var mockNamespace = MockImplBuilder.GetMockNamespace(model);

Expand Down Expand Up @@ -104,7 +104,7 @@ public static string Build(MockTypeModel model)
// setup/verify. Static abstract methods legitimately use ExplicitInterfaceName
// for bridge interface generation and still need setup extensions.
var instanceMethods = new EquatableArray<MockMemberModel>(
model.Methods.Where(m => m.ExplicitInterfaceName is null || m.IsStaticAbstract).ToImmutableArray());
model.Methods.Where(IsConfigurableMethod).ToImmutableArray());
var methodsWithDisambiguation = ApplyOutDisambiguation(instanceMethods);

// Methods
Expand All @@ -118,7 +118,9 @@ public static string Build(MockTypeModel model)
// Properties -- extension properties via C# 14 extension blocks
// (skip ref struct properties — can't use PropertyMockCall<RefStruct>)
var memberProps = model.Properties
.Where(p => p.IsConfigurableSurfaceProperty && (p.ExplicitInterfaceName is null || p.IsStaticAbstract))
.Where(p => p.IsSignatureAccessibleFromAssembly
&& p.IsConfigurableSurfaceProperty
&& (p.ExplicitInterfaceName is null || p.IsStaticAbstract))
.ToList();
if (memberProps.Count > 0)
{
Expand All @@ -132,6 +134,7 @@ public static string Build(MockTypeModel model)
// Each indexer overload (different parameter signature) gets its own pair.
var indexers = model.Properties
.Where(p => p.IsIndexer
&& p.IsSignatureAccessibleFromAssembly
&& !p.IsStaticAbstract
&& !p.IsRefStructReturn
&& !p.IsReturnTypeStaticAbstractInterface)
Expand Down Expand Up @@ -178,21 +181,29 @@ public static string Build(MockTypeModel model)

// The out/ref setter delegates stay beside the mocked type: the generated impl references
// them through GetGlobalMockNamespacePrefix, and they are named from the type's short name.
EmitOutRefSetterDelegateNamespace(writer, model, hasEvents, mockNamespace);
EmitOutRefSetterDelegateNamespace(writer, model, mockNamespace);

return writer.ToString();
}

/// <summary>Methods that get a typed call wrapper — the shared filter for both emission passes.</summary>
private static IEnumerable<MockMemberModel> WrappedMethods(MockTypeModel model, bool hasEvents)
=> model.Methods.Where(m =>
(m.ExplicitInterfaceName is null || m.IsStaticAbstract)
IsConfigurableMethod(m)
&& ShouldGenerateTypedWrapper(m, model, hasEvents));

private static void EmitOutRefSetterDelegateNamespace(CodeWriter writer, MockTypeModel model, bool hasEvents, string mockNamespace)
private static bool IsConfigurableMethod(MockMemberModel method)
=> method.IsSignatureAccessibleFromAssembly
&& (method.ExplicitInterfaceName is null || method.IsStaticAbstract);
Comment on lines +196 to +197

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude hidden methods from secondary-surface collisions

When a multi-type mock's primary class has an inaccessible method such as protected State Get() and an additional interface exposes int Get(), this predicate removes the primary method from the generated member surface, but SecondarySurfaceFactory.CreateContext still adds it to PrimaryMethodNameParams. Consequently, BuildPairModel unnecessarily renames the accessible interface setup to IExtra_Get, even though no primary Get extension exists to conflict with it. Apply the same accessibility predicate when constructing the primary collision context so the secondary API retains its expected name.

Useful? React with 👍 / 👎.


private static bool IsConfigurableEvent(MockEventModel evt)
=> evt.IsSignatureAccessibleFromAssembly && !evt.IsStaticAbstract;

private static void EmitOutRefSetterDelegateNamespace(CodeWriter writer, MockTypeModel model, string mockNamespace)
{
var methodsNeedingDelegates = WrappedMethods(model, hasEvents)
.Where(m => MockImplBuilder.SupportsClosedRefStructSetter(model, m)
var methodsNeedingDelegates = model.Methods
.Where(m => !m.IsStaticAbstract
&& MockImplBuilder.SupportsClosedRefStructSetter(model, m)
&& m.Parameters.Any(p => p.Direction is ParameterDirection.Out or ParameterDirection.Ref
&& p.IsNonSpanRefStruct))
.ToList();
Expand Down Expand Up @@ -1079,7 +1090,7 @@ private static (bool UseTypedWrapper, string ReturnType, string SetupReturnType)
? method.UnwrappedReturnType
: method.ReturnType;

var hasEvents = model.Events.Any(e => !e.IsStaticAbstract);
var hasEvents = model.Events.Any(IsConfigurableEvent);
var useTypedWrapper = ShouldGenerateTypedWrapper(method, model, hasEvents);

string returnType;
Expand Down Expand Up @@ -1223,9 +1234,8 @@ private static void EmitAnyArgsOverload(CodeWriter writer, MockMemberModel metho

// Name uniqueness: same set of methods that drive extension-method emission.
int sameNameCount = 0;
foreach (var m in model.Methods)
foreach (var m in model.Methods.Where(IsConfigurableMethod))
{
if (m.ExplicitInterfaceName is not null && !m.IsStaticAbstract) continue;
if (m.Name == method.Name) sameNameCount++;
}
if (sameNameCount > 1) return;
Expand Down Expand Up @@ -1607,7 +1617,7 @@ private static void GenerateRaiseExtensionMethods(CodeWriter writer, MockTypeMod
var typeParams = MockImplBuilder.GetTypeParameterList(model);
var constraints = MockImplBuilder.GetConstraintClauses(model);
bool first = true;
foreach (var evt in model.Events.Where(e => !e.IsStaticAbstract))
foreach (var evt in model.Events.Where(IsConfigurableEvent))
{
if (!first) writer.AppendLine();
first = false;
Expand Down Expand Up @@ -1765,10 +1775,9 @@ private static void EmitParamsAnyArgOverload(CodeWriter writer, MockMemberModel

// Two same-name params methods that differ only in element type (e.g. M(params int[]) and
// M(params string[])) would both produce this AnyArg-slotted signature — skip on collision.
foreach (var m in model.Methods)
foreach (var m in model.Methods.Where(IsConfigurableMethod))
{
if (m.MemberId == method.MemberId || m.Name != method.Name) continue;
if (m.ExplicitInterfaceName is not null && !m.IsStaticAbstract) continue;
if (m.TypeParameters.Length != method.TypeParameters.Length) continue;
var mLast = m.Parameters.Length > 0 ? m.Parameters[m.Parameters.Length - 1] : null;
if (mLast is null || mLast.ParamsElementType is null || mLast.Direction != ParameterDirection.In) continue;
Expand Down
25 changes: 21 additions & 4 deletions src/TUnit.Mocks.SourceGenerator/Discovery/MemberDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ private static void CollectMembers(
}

var explicitName = RequiresExplicitImpl(primaryClassSymbol, evt) ? interfaceFqn : null;
state.Events.Add(Tag(CreateEventModel(evt, explicitName, interfaceFqn), ownerTypeIndex));
state.Events.Add(Tag(CreateEventModel(evt, explicitName, interfaceFqn, compilation: compilation), ownerTypeIndex));
break;
}
}
Expand Down Expand Up @@ -511,7 +511,7 @@ private static void ProcessClassMembers(
if (evt.IsAbstract || evt.IsVirtual || evt.IsOverride)
{
if (!seenEvents.Add(key)) continue;
events.Add(CreateEventModel(evt, null, compilationAssembly: compilationAssembly));
events.Add(CreateEventModel(evt, null, compilationAssembly: compilationAssembly, compilation: compilation));
}
else
{
Expand Down Expand Up @@ -748,12 +748,21 @@ private static MockMemberModel CreateMethodModel(IMethodSymbol method, ref int m
OverrideAccessModifier = GetOverrideAccessModifier(method, compilationAssembly),
IsRefStructReturn = returnType.IsRefLikeType,
AutoMockFactoryMethod = autoMockFactoryMethod,
IsSignatureAccessibleFromAssembly = IsMethodSignatureAccessibleFromAssembly(method, compilation),
IsReturnTypeStaticAbstractInterface = returnTypeHasStaticAbstract,
SpanReturnElementType = returnType.IsRefLikeType ? GetSpanElementType(returnType) : null,
ObsoleteAttribute = GetObsoleteAttributeSyntax(method)
};
}

private static bool IsMethodSignatureAccessibleFromAssembly(IMethodSymbol method, Compilation compilation)
=> TypeAccessibility.IsAccessibleFromAssembly(method.ReturnType, compilation)
&& method.Parameters.All(parameter =>
TypeAccessibility.IsAccessibleFromAssembly(parameter.Type, compilation))
&& method.TypeParameters.All(typeParameter =>
typeParameter.ConstraintTypes.All(constraint =>
TypeAccessibility.IsAccessibleFromAssembly(constraint, compilation)));
Comment on lines +762 to +764

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Interface wrappers bypass constraint filtering

When a mocked generic interface method is constrained to a type inaccessible from the consumer assembly, MockWrapperTypeBuilder emits forwarding and generic wrapper declarations without applying IsSignatureAccessibleFromAssembly, causing the generated consumer code to fail compilation with an accessibility error such as CS0122.

Knowledge Base Used: TUnit.Mocks: source-generated mocking


/// <summary>
/// When a property with the same name appears from multiple interfaces, merge getter/setter
/// accessors so the generated class satisfies all interfaces.
Expand Down Expand Up @@ -819,6 +828,7 @@ private static MockMemberModel CreatePropertyModel(IPropertySymbol property, ref
SetterAccessModifier = GetAccessorAccessModifier(property.SetMethod, overrideAccessModifier, compilationAssembly),
IsRefStructReturn = property.Type.IsRefLikeType,
AutoMockFactoryMethod = GetAutoMockFactoryMethod(property.Type, compilation),
IsSignatureAccessibleFromAssembly = IsPropertySignatureAccessibleFromAssembly(property, compilation),
IsReturnTypeStaticAbstractInterface = IsInterfaceWithStaticAbstractMembers(property.Type),
SpanReturnElementType = property.Type.IsRefLikeType ? GetSpanElementType(property.Type) : null,
ObsoleteAttribute = propertyObsolete,
Expand All @@ -827,6 +837,11 @@ private static MockMemberModel CreatePropertyModel(IPropertySymbol property, ref
};
}

private static bool IsPropertySignatureAccessibleFromAssembly(IPropertySymbol property, Compilation compilation)
=> TypeAccessibility.IsAccessibleFromAssembly(property.Type, compilation)
&& property.Parameters.All(parameter =>
TypeAccessibility.IsAccessibleFromAssembly(parameter.Type, compilation));

/// <summary>Returns the [Obsolete] attribute for a single accessor, but only when the
/// containing property is NOT itself marked obsolete. When the property is marked, the
/// property-level emission already covers the accessor and emitting both would duplicate.
Expand Down Expand Up @@ -944,6 +959,7 @@ private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref i
SetterAccessModifier = GetAccessorAccessModifier(indexer.SetMethod, overrideAccessModifier, compilationAssembly),
IsRefStructReturn = indexer.Type.IsRefLikeType,
AutoMockFactoryMethod = GetAutoMockFactoryMethod(indexer.Type, compilation),
IsSignatureAccessibleFromAssembly = IsPropertySignatureAccessibleFromAssembly(indexer, compilation),
IsReturnTypeStaticAbstractInterface = IsInterfaceWithStaticAbstractMembers(indexer.Type),
SpanReturnElementType = indexer.Type.IsRefLikeType ? GetSpanElementType(indexer.Type) : null,
ObsoleteAttribute = indexerObsolete,
Expand Down Expand Up @@ -991,7 +1007,7 @@ private static MockMemberModel CreateIndexerModel(IPropertySymbol indexer, ref i
return $"{globalPrefix}{baseName}MockFactory.CreateAutoMock<{typeArguments}>";
}

private static MockEventModel CreateEventModel(IEventSymbol evt, string? explicitInterfaceName, string? declaringInterfaceName = null, IAssemblySymbol? compilationAssembly = null)
private static MockEventModel CreateEventModel(IEventSymbol evt, string? explicitInterfaceName, string? declaringInterfaceName = null, IAssemblySymbol? compilationAssembly = null, Compilation compilation = null!)
{
var eventHandlerType = evt.Type.GetFullyQualifiedNameWithNullability();

Expand Down Expand Up @@ -1052,6 +1068,7 @@ private static MockEventModel CreateEventModel(IEventSymbol evt, string? explici
ExplicitInterfaceName = explicitInterfaceName,
DeclaringInterfaceName = declaringInterfaceName,
OverrideAccessModifier = GetOverrideAccessModifier(evt, compilationAssembly),
IsSignatureAccessibleFromAssembly = TypeAccessibility.IsAccessibleFromAssembly(evt.Type, compilation),
RaiseParameterList = raiseParameterList,
ObsoleteAttribute = GetObsoleteAttributeSyntax(evt)
};
Expand Down Expand Up @@ -1335,7 +1352,7 @@ private static void CollectStaticAbstractMember(
var key = $"E:{evt.Name}";
if (!seenEvents.Add(key)) break;

var model = CreateEventModel(evt, interfaceFqn) with
var model = CreateEventModel(evt, interfaceFqn, compilation: compilation) with
{
IsStaticAbstract = true
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public static bool IsAccessibleFromAssembly(ITypeSymbol type, Compilation compil
{
switch (type)
{
case IErrorTypeSymbol:
return false;

case ITypeParameterSymbol:
return true;

Expand Down
8 changes: 8 additions & 0 deletions src/TUnit.Mocks.SourceGenerator/Models/MockEventModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ internal sealed record MockEventModel : IEquatable<MockEventModel>
public string OverrideAccessModifier { get; init; } = "public";
public bool IsStaticAbstract { get; init; }

/// <summary>
/// Whether non-derived generated code in the consumer assembly can name the event handler
/// type. Inaccessible events remain implemented but get no typed raise surface.
/// </summary>
public bool IsSignatureAccessibleFromAssembly { get; init; } = true;

/// <summary>
/// Which type in a multi-type mock owns this event: 0 = the primary type,
/// n = 1-based index into <see cref="MockTypeModel.AdditionalInterfaceNames"/>.
Expand Down Expand Up @@ -75,6 +81,7 @@ public bool Equals(MockEventModel? other)
&& AdditionalExplicitInterfaceNames.Equals(other.AdditionalExplicitInterfaceNames)
&& OverrideAccessModifier == other.OverrideAccessModifier
&& IsStaticAbstract == other.IsStaticAbstract
&& IsSignatureAccessibleFromAssembly == other.IsSignatureAccessibleFromAssembly
&& OwnerTypeIndex == other.OwnerTypeIndex
&& RaiseParameterList == other.RaiseParameterList
&& ObsoleteAttribute == other.ObsoleteAttribute;
Expand All @@ -92,6 +99,7 @@ public override int GetHashCode()
hash = hash * 31 + (DeclaringInterfaceName?.GetHashCode() ?? 0);
hash = hash * 31 + AdditionalExplicitInterfaceNames.GetHashCode();
hash = hash * 31 + OverrideAccessModifier.GetHashCode();
hash = hash * 31 + IsSignatureAccessibleFromAssembly.GetHashCode();
hash = hash * 31 + ObsoleteAttribute.GetHashCode();
hash = hash * 31 + OwnerTypeIndex;
return hash;
Expand Down
9 changes: 9 additions & 0 deletions src/TUnit.Mocks.SourceGenerator/Models/MockMemberModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ internal sealed record MockMemberModel : IEquatable<MockMemberModel>
public bool IsStaticAbstract { get; init; }
public string? AutoMockFactoryMethod { get; init; }

/// <summary>
/// Whether non-derived generated code in the consumer assembly can name every type in this
/// member's signature. Inaccessible members still need an override when abstract, but cannot
/// have setup or verification extensions generated for them.
/// </summary>
public bool IsSignatureAccessibleFromAssembly { get; init; } = true;

/// <summary>
/// Which type in a multi-type mock owns this member: 0 = the primary type,
/// n = 1-based index into <see cref="MockTypeModel.AdditionalInterfaceNames"/>.
Expand Down Expand Up @@ -173,6 +180,7 @@ public bool Equals(MockMemberModel? other)
&& IsRefStructReturn == other.IsRefStructReturn
&& IsStaticAbstract == other.IsStaticAbstract
&& AutoMockFactoryMethod == other.AutoMockFactoryMethod
&& IsSignatureAccessibleFromAssembly == other.IsSignatureAccessibleFromAssembly
&& OwnerTypeIndex == other.OwnerTypeIndex
&& IsReturnTypeStaticAbstractInterface == other.IsReturnTypeStaticAbstractInterface
&& SpanReturnElementType == other.SpanReturnElementType
Expand All @@ -195,6 +203,7 @@ public override int GetHashCode()
hash = hash * 31 + GetterAccessModifier.GetHashCode();
hash = hash * 31 + SetterAccessModifier.GetHashCode();
hash = hash * 31 + (AutoMockFactoryMethod?.GetHashCode() ?? 0);
hash = hash * 31 + IsSignatureAccessibleFromAssembly.GetHashCode();
hash = hash * 31 + OwnerTypeIndex;
hash = hash * 31 + IsReturnTypeStaticAbstractInterface.GetHashCode();
hash = hash * 31 + (ExplicitInterfaceName?.GetHashCode() ?? 0);
Expand Down
Loading
Loading