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
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ protected override async Task<Document> ConvertAssertionAsync(CodeFixContext con
Dictionary<InvocationExpressionSyntax, InvocationExpressionSyntax> verifyReplacements =
FindAndBuildVerifyReplacements(allInvocations, semanticModel, mockSymbol, cancellationToken);

Dictionary<AssignmentExpressionSyntax, InvocationExpressionSyntax> propertyVerifyReplacements =
FindAndBuildPropertyVerifyReplacements(compilationUnit, semanticModel, mockSymbol, cancellationToken);

Dictionary<InvocationExpressionSyntax, InvocationExpressionSyntax> clearReplacements =
FindAndBuildClearReceivedCallsReplacements(allInvocations, semanticModel, mockSymbol, cancellationToken);

Expand All @@ -83,6 +86,7 @@ protected override async Task<Document> ConvertAssertionAsync(CodeFixContext con
nodesToReplace.AddRange(clearReplacements.Keys);
nodesToReplace.AddRange(raiseReplacements.Keys);
nodesToReplace.AddRange(whenDoReplacements.Keys);
nodesToReplace.AddRange(propertyVerifyReplacements.Keys);

compilationUnit = compilationUnit.ReplaceNodes(
nodesToReplace,
Expand Down Expand Up @@ -116,10 +120,17 @@ protected override async Task<Document> ConvertAssertionAsync(CodeFixContext con
}
}

if (original is AssignmentExpressionSyntax assignment &&
raiseReplacements.TryGetValue(assignment, out InvocationExpressionSyntax? raiseReplacement))
if (original is AssignmentExpressionSyntax assignment)
{
return raiseReplacement;
if (raiseReplacements.TryGetValue(assignment, out InvocationExpressionSyntax? raiseReplacement))
{
return raiseReplacement;
}

if (propertyVerifyReplacements.TryGetValue(assignment, out InvocationExpressionSyntax? propVerifyReplacement))
{
return propVerifyReplacement;
}
}

return original;
Expand All @@ -132,7 +143,7 @@ protected override async Task<Document> ConvertAssertionAsync(CodeFixContext con
compilationUnit = compilationUnit.AddUsings(usingDirective);
}

if (verifyReplacements.Count > 0)
if (verifyReplacements.Count > 0 || propertyVerifyReplacements.Count > 0)
{
bool hasVerifyUsing = compilationUnit.Usings.Any(u => u.Name?.ToString() == "Mockolate.Verify");
if (!hasVerifyUsing)
Expand Down Expand Up @@ -577,6 +588,167 @@ private static Dictionary<InvocationExpressionSyntax, InvocationExpressionSyntax
return result;
}

/// <summary>
/// Translates property-style verifications:
/// <list type="bullet">
/// <item><c>_ = sub.Received().Prop</c> → <c>sub.Mock.Verify.Prop.Got().AtLeastOnce()</c></item>
/// <item><c>sub.Received().Prop = v</c> → <c>sub.Mock.Verify.Prop.Set(v).AtLeastOnce()</c></item>
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

The XML doc example for property sets shows sub.Mock.Verify.Prop.Set(v)..., but the implementation wraps non-matcher values in It.Is(...) (e.g., Set(It.Is("x"))). Consider updating the example/comment to reflect the actual output so the documentation matches behavior.

Suggested change
/// <item><c>sub.Received().Prop = v</c> → <c>sub.Mock.Verify.Prop.Set(v).AtLeastOnce()</c></item>
/// <item><c>sub.Received().Prop = "x"</c> → <c>sub.Mock.Verify.Prop.Set(It.Is("x")).AtLeastOnce()</c></item>

Copilot uses AI. Check for mistakes.
/// </list>
/// <c>Received(n)</c> / <c>DidNotReceive()</c> map to <c>Exactly(n)</c>/<c>Once()</c>/<c>Never()</c> in the same way as method-style.
/// </summary>
private static Dictionary<AssignmentExpressionSyntax, InvocationExpressionSyntax> FindAndBuildPropertyVerifyReplacements(
CompilationUnitSyntax compilationUnit,
SemanticModel? semanticModel,
ISymbol? mockSymbol,
CancellationToken cancellationToken)
{
if (semanticModel is null || mockSymbol is null)
{
return [];
}

Dictionary<AssignmentExpressionSyntax, InvocationExpressionSyntax> result = [];

foreach (AssignmentExpressionSyntax assignment in compilationUnit.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (!assignment.IsKind(SyntaxKind.SimpleAssignmentExpression))
{
continue;
}

Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

FindAndBuildPropertyVerifyReplacements rewrites any matching SimpleAssignmentExpression regardless of where the assignment appears. Since assignment expressions can be used as sub-expressions (not just statements), this code fix could change semantics or produce non-compiling code when the assignment’s value is used. Consider restricting rewrites to assignments whose parent is an ExpressionStatementSyntax (and, for the get case, a discard assignment used as a statement) to ensure only verification-style usages are migrated.

Suggested change
if (assignment.Parent is not ExpressionStatementSyntax)
{
continue;
}

Copilot uses AI. Check for mistakes.
// Got pattern: `_ = sub.Received().Prop`
if (assignment.Left is IdentifierNameSyntax { Identifier.Text: "_", } &&
TryExtractReceivedPropertyAccess(assignment.Right, semanticModel, mockSymbol, cancellationToken) is { } got)
{
result[assignment] = BuildPropertyVerifyChain(got.MockReceiver, got.PropertyName, "Got",
SyntaxFactory.ArgumentList(), got.ReceivedMethod, got.ReceivedArgs)
.WithTriviaFrom(assignment);
continue;
}

// Set pattern: `sub.Received().Prop = value`
if (TryExtractReceivedPropertyAccess(assignment.Left, semanticModel, mockSymbol, cancellationToken) is { } set)
{
ArgumentListSyntax setArgs = BuildPropertyVerifySetArgs(
assignment.Right, semanticModel, cancellationToken);

result[assignment] = BuildPropertyVerifyChain(set.MockReceiver, set.PropertyName, "Set",
setArgs, set.ReceivedMethod, set.ReceivedArgs)
.WithTriviaFrom(assignment);
}
}

return result;
}

private static (ExpressionSyntax MockReceiver, SimpleNameSyntax PropertyName, string ReceivedMethod,
ArgumentListSyntax ReceivedArgs)? TryExtractReceivedPropertyAccess(ExpressionSyntax expression,
SemanticModel semanticModel, ISymbol mockSymbol, CancellationToken cancellationToken)
{
if (expression is not MemberAccessExpressionSyntax propertyAccess ||
propertyAccess.Expression is not InvocationExpressionSyntax receivedInvocation ||
receivedInvocation.Expression is not MemberAccessExpressionSyntax receivedAccess)
{
return null;
Comment on lines +648 to +652
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

TryExtractReceivedPropertyAccess only checks the syntax shape sub.Received().X but doesn’t confirm that X resolves to an actual property. This can mis-rewrite cases like method-group accesses (e.g., _ = sub.Received().SomeMethod;) into Verify.SomeMethod.Got() which likely won’t compile. Consider using the semantic model to ensure propertyAccess (or propertyAccess.Name) binds to an IPropertySymbol before treating it as a property verification.

Copilot uses AI. Check for mistakes.
}

string method = receivedAccess.Name.Identifier.Text;
if (method is not ("Received" or "DidNotReceive"))
{
return null;
}

if (!IsTrackedMockReceiver(receivedAccess.Expression, semanticModel, mockSymbol, cancellationToken))
{
return null;
}

return (receivedAccess.Expression, propertyAccess.Name, method, receivedInvocation.ArgumentList);
}

private static InvocationExpressionSyntax BuildPropertyVerifyChain(ExpressionSyntax? mockReceiver,
SimpleNameSyntax? propertyName, string accessor, ArgumentListSyntax accessorArgs,
string? receivedMethod, ArgumentListSyntax? receivedArgs)
{
MemberAccessExpressionSyntax mockAccess = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
mockReceiver!,
SyntaxFactory.IdentifierName("Mock"));
MemberAccessExpressionSyntax verifyMember = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
mockAccess,
SyntaxFactory.IdentifierName("Verify"));
MemberAccessExpressionSyntax propAccess = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
verifyMember,
propertyName!.WithoutTrivia());
MemberAccessExpressionSyntax accessorAccess = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
propAccess,
SyntaxFactory.IdentifierName(accessor));
InvocationExpressionSyntax accessorCall = SyntaxFactory.InvocationExpression(accessorAccess, accessorArgs);

bool isNegative = receivedMethod == "DidNotReceive";
return BuildVerifySuffix(accessorCall, isNegative, receivedArgs ?? SyntaxFactory.ArgumentList());
}

/// <summary>
/// Builds the argument list for a property setter <c>Verify.Prop.Set(...)</c> call. Translates any
/// NSubstitute <c>Arg.*</c> matchers in the value to their <c>It.*</c> equivalents; non-matcher values are
/// wrapped in <c>It.Is(...)</c> for an explicit equality match.
/// </summary>
private static ArgumentListSyntax BuildPropertyVerifySetArgs(ExpressionSyntax valueExpression,
SemanticModel semanticModel, CancellationToken cancellationToken)
{
// Resolve Arg.* invocations against the original (still-attached) tree so the semantic model
// can answer GetSymbolInfo, then rewrite them in a detached copy.
InvocationExpressionSyntax[] argInvocations = valueExpression.DescendantNodesAndSelf()
.OfType<InvocationExpressionSyntax>()
.Where(invocation => IsNSubstituteArgCall(invocation, semanticModel, cancellationToken))
.ToArray();

ExpressionSyntax transformedValue = argInvocations.Length == 0
? valueExpression.WithoutTrivia()
: valueExpression.ReplaceNodes(argInvocations, TransformNSubstituteArgInvocation).WithoutTrivia();

if (IsRootedInItInvocation(transformedValue))
{
return SyntaxFactory.ArgumentList(
SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(transformedValue)));
}

InvocationExpressionSyntax itIs = SyntaxFactory.InvocationExpression(
SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
SyntaxFactory.IdentifierName("It"),
SyntaxFactory.IdentifierName("Is")),
SyntaxFactory.ArgumentList(
SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(transformedValue))));
return SyntaxFactory.ArgumentList(
SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(itIs)));
}

private static bool IsRootedInItInvocation(ExpressionSyntax expression)
{
ExpressionSyntax current = expression;
while (current is InvocationExpressionSyntax invocation)
{
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
{
return false;
}

if (memberAccess.Expression is IdentifierNameSyntax { Identifier.Text: "It", })
{
return true;
}

current = memberAccess.Expression;
}

return false;
}

private static Dictionary<InvocationExpressionSyntax, InvocationExpressionSyntax> FindAndBuildVerifyReplacements(
IReadOnlyList<InvocationExpressionSyntax> allInvocations,
SemanticModel? semanticModel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,74 @@ public void Test()
}
""");

[Fact]
public async Task DidNotReceivePropertySet_RewritesToVerifySetNever()
=> await Verifier.VerifyCodeFixAsync(
"""
using NSubstitute;

public interface IFoo { string Name { get; set; } }

public class Tests
{
public void Test()
{
var sub = [|Substitute.For<IFoo>()|];
sub.DidNotReceive().Name = "x";
}
}
""",
"""
using NSubstitute;
using Mockolate;
using Mockolate.Verify;

public interface IFoo { string Name { get; set; } }

public class Tests
{
public void Test()
{
var sub = IFoo.CreateMock();
sub.Mock.Verify.Name.Set(It.Is("x")).Never();
}
}
""");

[Fact]
public async Task DidNotReceivePropertySetWithArgAny_TranslatesMatcherWithoutWrapping()
=> await Verifier.VerifyCodeFixAsync(
"""
using NSubstitute;

public interface IFoo { string Name { get; set; } }

public class Tests
{
public void Test()
{
var sub = [|Substitute.For<IFoo>()|];
sub.DidNotReceive().Name = Arg.Any<string>();
}
}
""",
"""
using NSubstitute;
using Mockolate;
using Mockolate.Verify;

public interface IFoo { string Name { get; set; } }

public class Tests
{
public void Test()
{
var sub = IFoo.CreateMock();
sub.Mock.Verify.Name.Set(It.IsAny<string>()).Never();
}
}
""");

[Fact]
public async Task DidNotReceiveWithAnyArgs_AddsAnyParametersAndNever()
=> await Verifier.VerifyCodeFixAsync(
Expand Down Expand Up @@ -179,6 +247,74 @@ public void Test()
}
""");

[Fact]
public async Task ReceivedPropertyGet_RewritesToVerifyGot()
=> await Verifier.VerifyCodeFixAsync(
"""
using NSubstitute;

public interface IFoo { string Name { get; set; } }

public class Tests
{
public void Test()
{
var sub = [|Substitute.For<IFoo>()|];
_ = sub.Received().Name;
}
}
""",
"""
using NSubstitute;
using Mockolate;
using Mockolate.Verify;

public interface IFoo { string Name { get; set; } }

public class Tests
{
public void Test()
{
var sub = IFoo.CreateMock();
sub.Mock.Verify.Name.Got().AtLeastOnce();
}
}
""");

[Fact]
public async Task ReceivedPropertySet_RewritesToVerifySet()
=> await Verifier.VerifyCodeFixAsync(
"""
using NSubstitute;

public interface IFoo { string Name { get; set; } }

public class Tests
{
public void Test()
{
var sub = [|Substitute.For<IFoo>()|];
sub.Received().Name = "x";
}
}
""",
"""
using NSubstitute;
using Mockolate;
using Mockolate.Verify;

public interface IFoo { string Name { get; set; } }

public class Tests
{
public void Test()
{
var sub = IFoo.CreateMock();
sub.Mock.Verify.Name.Set(It.Is("x")).AtLeastOnce();
}
}
""");

[Fact]
public async Task ReceivedWithAnyArgs_AddsAnyParameters()
=> await Verifier.VerifyCodeFixAsync(
Expand Down
Loading