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
113 changes: 109 additions & 4 deletions Source/Mockolate.SourceGenerators/Entities/MockClass.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;

namespace Mockolate.SourceGenerators.Entities;
Expand All @@ -11,6 +12,11 @@ public MockClass(ITypeSymbol[] types, IAssemblySymbol sourceAssembly) : base(typ
AdditionalImplementations = new EquatableArray<Class>(
types.Skip(1).Select(x => new Class(x, sourceAssembly)).ToArray());

HiddenBaseInterfaces = IsInterface
? new EquatableArray<Class>(GetHiddenBaseInterfaces(types[0])
.Select(x => new Class(x, sourceAssembly)).ToArray())
: new EquatableArray<Class>([]);

if (!IsInterface && types[0] is INamedTypeSymbol namedTypeSymbol)
{
Constructors =
Expand All @@ -34,13 +40,21 @@ public MockClass(ITypeSymbol[] types, IAssemblySymbol sourceAssembly) : base(typ

public EquatableArray<Class> AdditionalImplementations { get; }

/// <summary>
/// Base interfaces whose members are hidden (via <see langword="new" />) by the mocked
/// interface. Their setup/verify surfaces are generated and implemented so the hidden slots are
/// reachable through <c>.Mock.As&lt;TBase&gt;()</c>. Distinct from
/// <see cref="AdditionalImplementations" /> (the user's explicit <c>Implementing&lt;T&gt;()</c>).
/// </summary>
public EquatableArray<Class> HiddenBaseInterfaces { get; }

/// <summary>
/// MockClass equality is keyed on <see cref="Class.ClassFullName" /> plus a content-derived
/// hash that folds the base surface together with the mock-only fields
/// (<see cref="AdditionalImplementations" />, <see cref="Constructors" />,
/// <see cref="Delegate" />). Two mocks of the same root with different additional
/// interfaces, different constructor surfaces, or different delegate signatures must hash
/// apart so Roslyn's incremental cache invalidates when any of those change.
/// (<see cref="AdditionalImplementations" />, <see cref="HiddenBaseInterfaces" />,
/// <see cref="Constructors" />, <see cref="Delegate" />). Two mocks of the same root with
/// different additional interfaces, different constructor surfaces, or different delegate
/// signatures must hash apart so Roslyn's incremental cache invalidates when any of those change.
/// </summary>
public bool Equals(MockClass? other)
=> ReferenceEquals(this, other) ||
Expand All @@ -55,6 +69,11 @@ public IEnumerable<Class> AllImplementations()
{
yield return additionalImplementation;
}

foreach (Class hiddenBaseInterface in HiddenBaseInterfaces)
{
yield return hiddenBaseInterface;
}
}

public override bool Equals(Class? other) => other is MockClass mc && Equals(mc);
Expand All @@ -67,6 +86,7 @@ private int ComputeMockSurfaceHash()
{
int hash = base.GetHashCode();
hash = unchecked((hash * 17) + AdditionalImplementations.GetHashCode());
hash = unchecked((hash * 17) + HiddenBaseInterfaces.GetHashCode());
if (Constructors is { } constructors)
{
hash = unchecked((hash * 17) + constructors.GetHashCode());
Expand All @@ -79,4 +99,89 @@ private int ComputeMockSurfaceHash()

return hash;
}

/// <summary>
/// Base interfaces of <paramref name="type" /> that declare a member which a more-derived
/// interface in the hierarchy hides (a <see langword="new" /> member with a matching signature).
/// The hidden base member is a separate interface slot, so its setup/verify surface must be
/// generated explicitly. Ordinary (non-hidden) inheritance returns nothing.
/// </summary>
private static IEnumerable<INamedTypeSymbol> GetHiddenBaseInterfaces(ITypeSymbol type)
{
ImmutableArray<INamedTypeSymbol> allInterfaces = type.AllInterfaces;
foreach (INamedTypeSymbol baseInterface in allInterfaces)
{
if (baseInterface.GetMembers().Any(member => member.IsStatic))
{
continue;
}

bool hasHiddenMember = false;
foreach (ISymbol baseMember in baseInterface.GetMembers())
{
if (!IsHidableMember(baseMember))
{
continue;
}

if (HidesMember(type, baseMember) ||
allInterfaces.Any(intermediate =>
!SymbolEqualityComparer.Default.Equals(intermediate, baseInterface) &&
intermediate.AllInterfaces.Contains(baseInterface, SymbolEqualityComparer.Default) &&
HidesMember(intermediate, baseMember)))
{
hasHiddenMember = true;
break;
}
}

if (hasHiddenMember)
{
yield return baseInterface;
}
}
}

private static bool HidesMember(ITypeSymbol hidingType, ISymbol baseMember)
=> hidingType.GetMembers(baseMember.Name)
.Any(candidate => !SymbolEqualityComparer.Default.Equals(candidate.ContainingType, baseMember.ContainingType) &&
SignatureMatches(candidate, baseMember));

private static bool IsHidableMember(ISymbol member)
=> member switch
{
IMethodSymbol { MethodKind: MethodKind.Ordinary, } => true,
IPropertySymbol => true,
IEventSymbol => true,
_ => false,
};

private static bool SignatureMatches(ISymbol a, ISymbol b)
=> a.Kind == b.Kind && (a, b) switch
{
(IMethodSymbol ma, IMethodSymbol mb) => ma.TypeParameters.Length == mb.TypeParameters.Length &&
ParametersMatch(ma.Parameters, mb.Parameters),
(IPropertySymbol pa, IPropertySymbol pb) => ParametersMatch(pa.Parameters, pb.Parameters),
(IEventSymbol, IEventSymbol) => true,
_ => false,
};

private static bool ParametersMatch(ImmutableArray<IParameterSymbol> a, ImmutableArray<IParameterSymbol> b)
{
if (a.Length != b.Length)
{
return false;
}

for (int i = 0; i < a.Length; i++)
{
if (a[i].RefKind != b[i].RefKind ||
!SymbolEqualityComparer.Default.Equals(a[i].Type, b[i].Type))
{
return false;
}
}

return true;
}
}
75 changes: 70 additions & 5 deletions Source/Mockolate.SourceGenerators/MockGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,18 @@
List<NamedMock> result = new(arr.Length);
HashSet<string> seenBaseClasses = new(StringComparer.Ordinal);

// Pass 1: assign disambiguated names to every distinct base/additional class. The order
// here must be deterministic so the same input set always yields the same names.
Dictionary<string, MockClass> primaryMocks = new(StringComparer.Ordinal);
foreach (MockClass mc in arr)

Check warning on line 360 in Source/Mockolate.SourceGenerators/MockGenerator.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Loops should be simplified using the "Where" LINQ method

See more on https://sonarcloud.io/project/issues?id=Testably_Mockolate&issues=AZ7lCXbpu5xpxItC5vIU&open=AZ7lCXbpu5xpxItC5vIU&pullRequest=802
{
if (IsValidMockDeclaration(mc))
{
primaryMocks[mc.ClassFullName] = mc;
}
}

// Pass 1a: assign disambiguated names to every distinct base/additional/hidden-base class. The
// order here must be deterministic so the same input set always yields the same names.
List<Class> orderedClasses = new();
foreach (MockClass mc in arr)
{
if (!IsValidMockDeclaration(mc))
Expand All @@ -381,8 +391,25 @@
}

baseClassNames[@class.ClassFullName] = actualName;
result.Add(new NamedMock(actualName, actualName, @class, null));
orderedClasses.Add(@class);
}
}

// Pass 1b: create one standalone NamedMock per distinct class. Names are now fully assigned, so
// hidden-base interfaces can be resolved to their disambiguated names.
foreach (Class @class in orderedClasses)
{
string actualName = baseClassNames[@class.ClassFullName];
EquatableArray<NamedClass>? hiddenBases = null;
if (primaryMocks.TryGetValue(@class.ClassFullName, out MockClass? primaryMock) &&
primaryMock.HiddenBaseInterfaces.Count > 0)
{
hiddenBases = new EquatableArray<NamedClass>(primaryMock.HiddenBaseInterfaces
.Select(hiddenBase => new NamedClass(baseClassNames[hiddenBase.ClassFullName], hiddenBase))
.ToArray());
}

result.Add(new NamedMock(actualName, actualName, @class, null, hiddenBases));
}

// Pass 2: combination mocks (additional implementations).
Expand Down Expand Up @@ -420,6 +447,15 @@
List<MockAsExtensionPair> ordered = new();
foreach (NamedMock nm in arr)
{
if (nm.HiddenBases is { } hiddenBases)
{
foreach (NamedClass hiddenBase in hiddenBases)
{
AddIfNew(seen, ordered, MockAsExtensionPair.Create(
nm.ParentName, nm.Mock.ClassFullName, hiddenBase.Name, hiddenBase.Class.ClassFullName));
}
}

if (nm.AdditionalClasses is not { } additional || additional.Count == 0)
{
continue;
Expand Down Expand Up @@ -471,8 +507,19 @@

if (named.AdditionalClasses is not { } additional || additional.Count == 0)
{
(string Name, Class Class)[] hiddenBaseArr = [];
if (named.HiddenBases is { } hiddenBases && hiddenBases.Count > 0)
{
NamedClass[] hiddenNamed = hiddenBases.AsArray();
hiddenBaseArr = new (string Name, Class Class)[hiddenNamed.Length];
for (int i = 0; i < hiddenNamed.Length; i++)
{
hiddenBaseArr[i] = (hiddenNamed[i].Name, hiddenNamed[i].Class);
}
}

context.AddSource($"Mock.{fileName}.g.cs",
ToSource(Sources.Sources.MockClass(named.ParentName, @class, hasOverloadResolutionPriority)));
ToSource(Sources.Sources.MockClass(named.ParentName, @class, hasOverloadResolutionPriority, hiddenBaseArr)));
return;
}

Expand Down Expand Up @@ -535,18 +582,21 @@

internal sealed class NamedMock : IEquatable<NamedMock>
{
public NamedMock(string fileName, string parentName, Class mock, EquatableArray<NamedClass>? additionalClasses)
public NamedMock(string fileName, string parentName, Class mock, EquatableArray<NamedClass>? additionalClasses,
EquatableArray<NamedClass>? hiddenBases = null)
{
FileName = fileName;
ParentName = parentName;
Mock = mock;
AdditionalClasses = additionalClasses;
HiddenBases = hiddenBases;
}

public string FileName { get; }
public string ParentName { get; }
public Class Mock { get; }
public EquatableArray<NamedClass>? AdditionalClasses { get; }
public EquatableArray<NamedClass>? HiddenBases { get; }

public bool Equals(NamedMock? other)
{
Expand All @@ -565,6 +615,11 @@
return false;
}

if (!NullableEquals(HiddenBases, other.HiddenBases))
{
return false;
}

if (AdditionalClasses is null)
{
return other.AdditionalClasses is null;
Expand All @@ -576,6 +631,11 @@
}

return AdditionalClasses.Value.Equals(other.AdditionalClasses.Value);

static bool NullableEquals(EquatableArray<NamedClass>? a, EquatableArray<NamedClass>? b)
{
return a is null ? b is null : b is not null && a.Value.Equals(b.Value);
}
}

public override bool Equals(object? obj) => Equals(obj as NamedMock);
Expand All @@ -590,6 +650,11 @@
hash = unchecked((hash * 17) + additional.GetHashCode());
}

if (HiddenBases is { } hiddenBases)
{
hash = unchecked((hash * 17) + hiddenBases.GetHashCode());
}

return hash;
}
}
Loading
Loading