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
114 changes: 99 additions & 15 deletions Source/Mockolate.Analyzers/MockabilityAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -466,18 +466,20 @@ private static bool IsMockable(ITypeSymbol typeSymbol, IAssemblySymbol sourceAss
return false;
}

if (FindInaccessibleRequiredMember(typeSymbol, sourceAssembly) is { } inaccessibleMember)
if (FindInaccessibleRequiredMember(typeSymbol, sourceAssembly) is { } inaccessible)
{
reason =
$"the member '{inaccessibleMember.ToDisplayString()}' must be implemented, but it is not accessible from this assembly";
reason = inaccessible.InaccessibleType is { } inaccessibleType
? $"the member '{inaccessible.Member.ToDisplayString()}' must be implemented, but its signature uses the type '{inaccessibleType.ToDisplayString()}', which is not accessible from this assembly"
: $"the member '{inaccessible.Member.ToDisplayString()}' must be implemented, but it is not accessible from this assembly";
return false;
}

reason = null;
return true;
}

private static ISymbol? FindInaccessibleRequiredMember(ITypeSymbol type, IAssemblySymbol sourceAssembly)
private static (ISymbol Member, ITypeSymbol? InaccessibleType)? FindInaccessibleRequiredMember(ITypeSymbol type,
IAssemblySymbol sourceAssembly)
{
HashSet<string> filledSlots = new(StringComparer.Ordinal);

Expand Down Expand Up @@ -559,17 +561,99 @@ private static IEnumerable<ISymbol> EnumerateFilledSlots(ISymbol member)
}
}

private static ISymbol? FindInaccessibleMember(ISymbol member, IAssemblySymbol sourceAssembly)
=> member switch
{
IMethodSymbol { MethodKind: MethodKind.Ordinary, IsAbstract: true, } method
=> IsAccessibleFrom(method, sourceAssembly) ? null : method,
IPropertySymbol { IsAbstract: true, } property
=> FindInaccessibleAccessor(property, sourceAssembly),
IEventSymbol { IsAbstract: true, } @event
=> IsAccessibleFrom(@event, sourceAssembly) ? null : @event,
_ => null,
};
private static (ISymbol Member, ITypeSymbol? InaccessibleType)? FindInaccessibleMember(ISymbol member,
IAssemblySymbol sourceAssembly)
{
switch (member)
{
case IMethodSymbol { MethodKind: MethodKind.Ordinary, IsAbstract: true, } method:
return !IsAccessibleFrom(method, sourceAssembly)
? (method, null)
: Combine(method, FindInaccessibleSignatureType(method, sourceAssembly));
case IPropertySymbol { IsAbstract: true, } property:
return FindInaccessibleAccessor(property, sourceAssembly) is { } accessor
? (accessor, null)
: Combine(property, FindInaccessibleSignatureType(property, sourceAssembly));
case IEventSymbol { IsAbstract: true, } @event:
return !IsAccessibleFrom(@event, sourceAssembly)
? (@event, null)
: Combine(@event, FindInaccessibleSignatureType(@event, sourceAssembly));
default:
return null;
}

static (ISymbol Member, ITypeSymbol? InaccessibleType)? Combine(ISymbol member, ITypeSymbol? inaccessibleType)
=> inaccessibleType is null ? null : (member, inaccessibleType);
}

/// <summary>
/// The first type named in <paramref name="member" />'s signature that the mock cannot restate,
/// or <see langword="null" /> when the whole signature is reachable. Mirrors
/// <c>Helpers.HasAccessibleSignature</c> in the source generator; keep both in sync.
/// </summary>
private static ITypeSymbol? FindInaccessibleSignatureType(ISymbol member, IAssemblySymbol sourceAssembly)
{
switch (member)
{
case IMethodSymbol method:
return FirstInaccessible([
method.ReturnType,
..method.Parameters.Select(parameter => parameter.Type),
..method.TypeParameters.SelectMany(typeParameter => typeParameter.ConstraintTypes),
]);
case IPropertySymbol property:
return FirstInaccessible([
property.Type, ..property.Parameters.Select(parameter => parameter.Type),
]);
case IEventSymbol @event:
return FirstInaccessible([@event.Type,]);
default:
return null;
}

ITypeSymbol? FirstInaccessible(IEnumerable<ITypeSymbol> types)
=> types.FirstOrDefault(type => !IsTypeAccessibleFrom(type, sourceAssembly));
}

/// <summary>
/// Mirror of <c>Helpers.IsAccessibleFrom</c>: every type in the containing chain and every
/// composed type must be public, or internal/protected internal with access granted. The
/// <see langword="protected" /> half never counts, because the surfaces naming the type do not
/// derive from the mocked type.
/// </summary>
private static bool IsTypeAccessibleFrom(ITypeSymbol type, IAssemblySymbol sourceAssembly)
{
switch (type)
{
case IArrayTypeSymbol array:
return IsTypeAccessibleFrom(array.ElementType, sourceAssembly);
case IPointerTypeSymbol pointer:
return IsTypeAccessibleFrom(pointer.PointedAtType, sourceAssembly);
case INamedTypeSymbol named:
for (INamedTypeSymbol? t = named; t is not null; t = t.ContainingType)
{
if (!IsDeclarationAccessible(t) ||
!t.TypeArguments.All(argument => IsTypeAccessibleFrom(argument, sourceAssembly)))
{
return false;
}
}

return true;
default:
return true;
}

bool IsDeclarationAccessible(INamedTypeSymbol candidate)
=> candidate.DeclaredAccessibility switch
{
Accessibility.Public => true,
Accessibility.Internal or Accessibility.ProtectedOrInternal =>
SymbolEqualityComparer.Default.Equals(candidate.ContainingAssembly, sourceAssembly) ||
candidate.ContainingAssembly?.GivesAccessTo(sourceAssembly) == true,
_ => false,
};
}

private static ISymbol? FindInaccessibleAccessor(IPropertySymbol property, IAssemblySymbol sourceAssembly)
{
Expand Down
13 changes: 10 additions & 3 deletions Source/Mockolate.SourceGenerators/Entities/Class.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,13 @@ bool ShouldIncludeMember(ISymbol member)

if (IsInterface || member.IsAbstract)
{
// An abstract member is kept even when it cannot be restated;
// `ComputeHasInaccessibleRequiredMember` then rejects the whole type.
return true;
}

return Helpers.IsOverridableFrom(member, _sourceAssembly);
return Helpers.IsOverridableFrom(member, _sourceAssembly) &&
Helpers.HasAccessibleSignature(member, _sourceAssembly);
}
}

Expand Down Expand Up @@ -257,7 +260,8 @@ private int ComputeSurfaceHash()

/// <summary>
/// True when a member the mock is still obliged to implement is invisible to the mock's
/// assembly, leaving no valid code the generator could emit for it.
/// assembly - either the member itself, or a type named in its signature - leaving no valid code
/// the generator could emit for it.
/// </summary>
/// <remarks>
/// Deliberately reads the filtered <see cref="Methods" />/<see cref="Properties" />/
Expand All @@ -280,7 +284,8 @@ private bool ComputeHasInaccessibleRequiredMember(List<Property> filledPropertie
/// <summary>
/// True when <paramref name="member" /> fills a base slot (by <see langword="override" /> or by
/// explicit interface implementation) that the mock must leave alone entirely, because the base
/// declaration or one of its accessors is invisible to <paramref name="sourceAssembly" />.
/// declaration, one of its accessors, or a type in its signature is invisible to
/// <paramref name="sourceAssembly" />.
/// </summary>
private static bool FillsInaccessibleBaseSlot(ISymbol member, IAssemblySymbol? sourceAssembly)
{
Expand All @@ -305,6 +310,7 @@ private static bool FillsInaccessibleBaseSlot(ISymbol member, IAssemblySymbol? s
IAssemblySymbol? sourceAssembly)
=> member is IPropertySymbol { IsAbstract: false, OverriddenProperty: { } slot, } &&
Helpers.IsOverridableFrom(slot, sourceAssembly) &&
Helpers.HasAccessibleSignature(slot, sourceAssembly) &&
HasUnreachableAccessor(slot, sourceAssembly)
? slot
: null;
Expand Down Expand Up @@ -359,6 +365,7 @@ private static IEnumerable<ISymbol> EnumerateFilledSlots(ISymbol member)

private static bool IsSlotReachable(ISymbol slot, IAssemblySymbol? sourceAssembly)
=> Helpers.IsOverridableFrom(slot, sourceAssembly) &&
Helpers.HasAccessibleSignature(slot, sourceAssembly) &&
(slot is not IPropertySymbol property || !HasUnreachableAccessor(property, sourceAssembly));

private static bool HasUnreachableAccessor(IPropertySymbol property, IAssemblySymbol? sourceAssembly)
Expand Down
3 changes: 2 additions & 1 deletion Source/Mockolate.SourceGenerators/Entities/Event.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public Event(IEventSymbol eventSymbol, IMethodSymbol delegateInvokeMethod, List<
OverrideAccessibility = Helpers.ResolveOverrideVisibility(
Accessibility, eventSymbol.ContainingAssembly, sourceAssembly);
UseOverride = eventSymbol.IsVirtual || eventSymbol.IsAbstract;
IsOverridableFromMock = Helpers.IsOverridableFrom(eventSymbol, sourceAssembly);
IsOverridableFromMock = Helpers.IsOverridableFrom(eventSymbol, sourceAssembly) &&
Helpers.HasAccessibleSignature(eventSymbol, sourceAssembly);
IsAbstract = eventSymbol.IsAbstract;
Name = Helpers.EscapeIfKeyword(eventSymbol.ExplicitInterfaceImplementations.Length > 0 ? eventSymbol.ExplicitInterfaceImplementations[0].Name : eventSymbol.Name);
Type = Type.From(eventSymbol.Type);
Expand Down
3 changes: 2 additions & 1 deletion Source/Mockolate.SourceGenerators/Entities/Method.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public Method(IMethodSymbol methodSymbol, List<Method>? alreadyDefinedMethods, I
OverrideAccessibility = Helpers.ResolveOverrideVisibility(
Accessibility, methodSymbol.ContainingAssembly, sourceAssembly);
UseOverride = methodSymbol.IsVirtual || methodSymbol.IsAbstract;
IsOverridableFromMock = Helpers.IsOverridableFrom(methodSymbol, sourceAssembly);
IsOverridableFromMock = Helpers.IsOverridableFrom(methodSymbol, sourceAssembly) &&
Helpers.HasAccessibleSignature(methodSymbol, sourceAssembly);
IsAbstract = methodSymbol.IsAbstract;
IsStatic = methodSymbol.IsStatic;
IsInitOnly = methodSymbol.IsInitOnly;
Expand Down
4 changes: 4 additions & 0 deletions Source/Mockolate.SourceGenerators/Entities/MockClass.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public MockClass(ITypeSymbol[] types, IAssemblySymbol sourceAssembly) : base(typ
.Where(x => x.DeclaredAccessibility == Accessibility.Protected ||
x.DeclaredAccessibility == Accessibility.ProtectedOrInternal ||
x.DeclaredAccessibility == Accessibility.Public)
// Parameter types are named verbatim in `MockExtensionsForXXX`, which does not derive
// from the mocked type: a type reachable only through inheritance would cause CS0122
// there and CS0051 on the generated `public` constructor.
.Where(x => x.Parameters.All(p => Helpers.IsAccessibleFrom(p.Type, sourceAssembly)))
.Select(x => new Method(x, null, sourceAssembly)).ToArray());
if (namedTypeSymbol.DelegateInvokeMethod is not null)
{
Expand Down
3 changes: 2 additions & 1 deletion Source/Mockolate.SourceGenerators/Entities/Property.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ public Property(IPropertySymbol propertySymbol, List<Property>? alreadyDefinedPr
bool setterOverridable = propertySymbol.SetMethod is not { } setterSymbol ||
Helpers.IsOverridableFrom(setterSymbol, sourceAssembly);
IsOverridableFromMock = getterOverridable && setterOverridable &&
Helpers.IsOverridableFrom(propertySymbol, sourceAssembly);
Helpers.IsOverridableFrom(propertySymbol, sourceAssembly) &&
Helpers.HasAccessibleSignature(propertySymbol, sourceAssembly);

Getter = propertySymbol.GetMethod is { } getter && getterOverridable
? new Method(getter, null, sourceAssembly)
Expand Down
102 changes: 65 additions & 37 deletions Source/Mockolate.SourceGenerators/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,69 @@ public static string ResolveOverrideVisibility(Accessibility accessibility,
_ => "private",
};

/// <summary>
/// Conservative visibility test for a type the generator names verbatim (attribute names,
/// constructor parameter types, member signatures): every type in the containing chain and
/// every composed type (array element, pointed-at type, type argument) must be public, or
/// internal/protected internal with access granted. The <c>protected</c> half never counts,
/// because the surfaces naming the type do not derive from the mocked type.
/// </summary>
public static bool IsAccessibleFrom(ITypeSymbol type, IAssemblySymbol? sourceAssembly)
{
switch (type)
{
case IArrayTypeSymbol array:
return IsAccessibleFrom(array.ElementType, sourceAssembly);
case IPointerTypeSymbol pointer:
return IsAccessibleFrom(pointer.PointedAtType, sourceAssembly);
case INamedTypeSymbol named:
// For `Outer<TArg>.Inner` the arguments sit on the containing type, so
// `named.TypeArguments` alone would miss them.
for (INamedTypeSymbol? t = named; t is not null; t = t.ContainingType)
{
if (!IsDeclarationAccessible(t) ||
!t.TypeArguments.All(argument => IsAccessibleFrom(argument, sourceAssembly)))
{
return false;
}
}

return true;
default:
return true;
}

bool IsDeclarationAccessible(INamedTypeSymbol candidate)
=> candidate.DeclaredAccessibility switch
{
Accessibility.Public => true,
Accessibility.Internal or Accessibility.ProtectedOrInternal =>
sourceAssembly is null || HasInternalAccess(candidate.ContainingAssembly, sourceAssembly),
_ => false,
};
}

/// <summary>
/// True when every type named in <paramref name="member" />'s signature (return/member type,
/// parameter types, generic constraint types) is accessible per <see cref="IsAccessibleFrom" />.
/// The mock restates them on surfaces that do <b>not</b> derive from the mocked type
/// (<c>IMockSetupForXXX</c>, <c>IMockVerifyForXXX</c>, <c>MockExtensionsForXXX</c>), so a
/// <see langword="protected" /> nested type causes CS0122 there even though the
/// <see langword="override" /> inside the mock class itself would compile.
/// </summary>
public static bool HasAccessibleSignature(ISymbol member, IAssemblySymbol? sourceAssembly)
=> member switch
{
IMethodSymbol method => IsAccessibleFrom(method.ReturnType, sourceAssembly) &&
method.Parameters.All(p => IsAccessibleFrom(p.Type, sourceAssembly)) &&
method.TypeParameters.All(p => p.ConstraintTypes
.All(c => IsAccessibleFrom(c, sourceAssembly))),
IPropertySymbol property => IsAccessibleFrom(property.Type, sourceAssembly) &&
property.Parameters.All(p => IsAccessibleFrom(p.Type, sourceAssembly)),
IEventSymbol @event => IsAccessibleFrom(@event.Type, sourceAssembly),
_ => true,
};

private static bool HasInternalAccess(IAssemblySymbol? containingAssembly, IAssemblySymbol? sourceAssembly)
{
if (sourceAssembly is null || containingAssembly is null)
Expand Down Expand Up @@ -263,6 +326,8 @@ public bool HasReservedName(string candidate)
{
public EquatableArray<Attribute>? ToAttributeArray(IAssemblySymbol? sourceAssembly = null)
{
// The attribute name is emitted verbatim, so an attribute class invisible to the mock's
// assembly would cause CS0122. Drop it instead of emitting uncompilable code.
Attribute[] consideredAttributes = attributes
.Where(x => x.AttributeClass is not null
&& !IsCompilerEmittedAttribute(x.AttributeClass)
Expand Down Expand Up @@ -292,43 +357,6 @@ static bool IsCompilerEmittedAttribute(INamedTypeSymbol attribute)
or "AsyncStateMachineAttribute" or "IteratorStateMachineAttribute"
or "AsyncIteratorStateMachineAttribute";
}

// The attribute name is emitted verbatim into the generated code (e.g.
// `[global::Azure.Core.CallerShouldAudit(...)]`). If the attribute class — or any of its
// containing types — is not visible to the generated mock assembly, referencing it causes
// CS0122. Drop the attribute instead of producing uncompilable output.
//
// Conservative rule: a type is accessible only if its whole containing chain is either
// Public, or Internal/ProtectedOrInternal with InternalsVisibleTo granted (or the
// same assembly). Private/Protected/ProtectedAndInternal nested types and
// ProtectedOrInternal across assemblies without IVT are treated as inaccessible — the
// `protected` half would require knowing the derivation relationship to the declaring
// type, which we don't verify here.
static bool IsAccessibleFrom(INamedTypeSymbol attribute, IAssemblySymbol? sourceAssembly)
{
for (INamedTypeSymbol? t = attribute; t is not null; t = t.ContainingType)
{
switch (t.DeclaredAccessibility)
{
case Accessibility.Public:
continue;
case Accessibility.Internal:
case Accessibility.ProtectedOrInternal:
if (sourceAssembly is null ||
SymbolEqualityComparer.Default.Equals(t.ContainingAssembly, sourceAssembly) ||
t.ContainingAssembly.GivesAccessTo(sourceAssembly))
{
continue;
}

return false;
default:
return false;
}
}

return true;
}
}
}

Expand Down
Loading
Loading