Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
85a9aa4
feat: support SimpleLambdaExpressionSyntax in callback validation (#1…
rjmurillo Mar 7, 2026
d571e85
fix: update code fix provider to handle SimpleLambdaExpressionSyntax
rjmurillo Mar 7, 2026
6fa1f5d
refactor: simplify CallbackSignatureShouldMatchMockedMethodFixer
rjmurillo Mar 7, 2026
55bf107
fix: preserve trivia when converting simple lambda to parenthesized l…
rjmurillo Mar 8, 2026
7bf23f5
refactor(codefix): extract shared boilerplate and add clarifying comm…
rjmurillo Mar 8, 2026
bba05ba
test(codefix): add code fix tests for simple lambda path
rjmurillo Mar 8, 2026
b0a39a3
fix: handle explicit delegate constructors in callback code fixer
rjmurillo Mar 8, 2026
04a79b5
fix: preserve original lambda parameter names in callback code fixer
rjmurillo Mar 8, 2026
c20fdce
fix: add delegate constructor bail-out for simple lambda callbacks
rjmurillo Mar 8, 2026
b3a9dba
Merge origin/main into feat/1012-simple-lambda-support
rjmurillo Mar 8, 2026
2a8c6e3
fix(code-fix): resolve ECS0200, ECS0900, and narrow delegate construc…
rjmurillo Mar 8, 2026
92843c7
refactor: simplify code in PR #1042
rjmurillo Mar 8, 2026
5323a48
refactor: reduce duplication and simplify fixer internals
rjmurillo Mar 8, 2026
75de196
Merge branch 'main' into feat/1012-simple-lambda-support
rjmurillo Mar 8, 2026
ae28b9a
fix: address review findings in simple lambda support
rjmurillo Mar 8, 2026
34aea00
fix: add happy-path tests for simple lambda conversion and use const …
rjmurillo Mar 8, 2026
9991096
fix: replace const with static readonly to satisfy ECS0200 analyzer
rjmurillo Mar 8, 2026
602c109
fix: address SA1512, xUnit1030, and MA0074 violations in code fix tests
rjmurillo Mar 8, 2026
33a352b
chore(deps): update dependency meziantou.analyzer to 3.0.20 (#1052)
renovate[bot] Mar 8, 2026
019ebef
perf: eliminate array allocations in constructor argument matching (#…
rjmurillo-bot Mar 8, 2026
bbce61f
refactor: remove unused CompositeAnalyzer in favor of AllAnalyzersVer…
rjmurillo-bot Mar 8, 2026
99f1163
refactor: eliminate DRY violations across analyzer pairs (#1044)
rjmurillo-bot Mar 8, 2026
87511e0
test: add coverage for simple lambda and overload resolution edge cases
rjmurillo Mar 8, 2026
b142928
chore: add third-party license notices for bundled dependencies (#1053)
rjmurillo-bot Mar 8, 2026
b497221
fix: update package snapshot files to include THIRD-PARTY-NOTICES.TXT
rjmurillo Mar 8, 2026
b5a8690
Merge remote-tracking branch 'origin/main' into feat/1012-simple-lamb…
rjmurillo Mar 8, 2026
21df808
refactor: eliminate DRY violations in code fix tests
rjmurillo Mar 8, 2026
1034d16
Merge branch 'main' into feat/1012-simple-lambda-support
rjmurillo Mar 8, 2026
0717e95
fix: address code review findings for simple lambda support
rjmurillo Mar 8, 2026
517457a
fix: revert const to static readonly (ECS0200) and extract test helper
rjmurillo Mar 9, 2026
817c0eb
Merge branch 'main' into feat/1012-simple-lambda-support
rjmurillo Mar 9, 2026
d250753
fix: pass lambda node directly to IsInsideDelegateConstructor and cla…
rjmurillo Mar 9, 2026
7e2930b
fix: address S2325 and MA0004 analyzer warnings in tests
rjmurillo Mar 9, 2026
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
37 changes: 24 additions & 13 deletions src/Analyzers/CallbackSignatureShouldMatchMockedMethodAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,14 @@ private static void Analyze(OperationAnalysisContext context, MoqKnownSymbols kn
return;
}

ParenthesizedLambdaExpressionSyntax? callbackLambda = TryGetCallbackLambda(callbackOrReturnsInvocation);
LambdaExpressionSyntax? callbackLambda = TryGetCallbackLambda(callbackOrReturnsInvocation);
if (callbackLambda == null)
{
return;
}

// Ignoring calls with no arguments because those are valid in Moq
SeparatedSyntaxList<ParameterSyntax> lambdaParameters = callbackLambda.ParameterList.Parameters;
SeparatedSyntaxList<ParameterSyntax> lambdaParameters = GetLambdaParameters(callbackLambda);
if (lambdaParameters.Count == 0)
{
return;
Expand All @@ -98,9 +98,9 @@ private static void Analyze(OperationAnalysisContext context, MoqKnownSymbols kn
ValidateCallbackAgainstSetup(context, semanticModel, setupInvocation, callbackLambda, lambdaParameters);
Comment thread
rjmurillo-bot marked this conversation as resolved.
}

private static ParenthesizedLambdaExpressionSyntax? TryGetCallbackLambda(InvocationExpressionSyntax callbackOrReturnsInvocation)
private static LambdaExpressionSyntax? TryGetCallbackLambda(InvocationExpressionSyntax callbackOrReturnsInvocation)
{
if (callbackOrReturnsInvocation.ArgumentList.Arguments[0]?.Expression is ParenthesizedLambdaExpressionSyntax directLambda)
if (callbackOrReturnsInvocation.ArgumentList.Arguments[0]?.Expression is LambdaExpressionSyntax directLambda)
{
return directLambda;
}
Expand All @@ -113,23 +113,34 @@ private static void Analyze(OperationAnalysisContext context, MoqKnownSymbols kn
}

// Extract the lambda from the delegate constructor (support both parenthesized and simple lambdas)
LambdaExpressionSyntax? lambdaExpression = delegateConstructor.ArgumentList!.Arguments[0]?.Expression as LambdaExpressionSyntax;
return delegateConstructor.ArgumentList!.Arguments[0]?.Expression as LambdaExpressionSyntax;
}

// Simple lambdas are currently skipped to avoid handling edge cases and maintain simplicity.
// TODO: Implement support for SimpleLambdaExpressionSyntax in delegate constructors.
if (lambdaExpression is SimpleLambdaExpressionSyntax)
private static SeparatedSyntaxList<ParameterSyntax> GetLambdaParameters(LambdaExpressionSyntax lambda)
{
return lambda switch
{
return null;
}
ParenthesizedLambdaExpressionSyntax parenthesized => parenthesized.ParameterList.Parameters,
SimpleLambdaExpressionSyntax simple => SyntaxFactory.SingletonSeparatedList(simple.Parameter),
_ => default,
};
}

return lambdaExpression as ParenthesizedLambdaExpressionSyntax;
private static SyntaxNode GetDiagnosticNode(LambdaExpressionSyntax lambda)
{
return lambda switch
{
ParenthesizedLambdaExpressionSyntax parenthesized => parenthesized.ParameterList,
SimpleLambdaExpressionSyntax simple => simple.Parameter,
_ => lambda,
};
}
Comment thread
rjmurillo-bot marked this conversation as resolved.

private static void ValidateCallbackAgainstSetup(
OperationAnalysisContext context,
SemanticModel semanticModel,
InvocationExpressionSyntax? setupInvocation,
ParenthesizedLambdaExpressionSyntax callbackLambda,
LambdaExpressionSyntax callbackLambda,
SeparatedSyntaxList<ParameterSyntax> lambdaParameters)
{
InvocationExpressionSyntax? mockedMethodInvocation = setupInvocation.FindMockedMethodInvocationFromSetupMethod();
Expand All @@ -143,7 +154,7 @@ private static void ValidateCallbackAgainstSetup(

if (mockedMethodArguments.Count != lambdaParameters.Count)
{
Diagnostic diagnostic = callbackLambda.ParameterList.CreateDiagnostic(Rule, methodName);
Diagnostic diagnostic = GetDiagnosticNode(callbackLambda).CreateDiagnostic(Rule, methodName);
context.ReportDiagnostic(diagnostic);
}
else
Expand Down
146 changes: 120 additions & 26 deletions src/CodeFixes/CallbackSignatureShouldMatchMockedMethodFixer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
[Shared]
public class CallbackSignatureShouldMatchMockedMethodFixer : CodeFixProvider
{
private const string FixTitle = "Fix Moq callback signature";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/// <inheritdoc />
public sealed override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(DiagnosticIds.BadCallbackParameters);

Expand All @@ -31,66 +33,158 @@
Diagnostic diagnostic = context.Diagnostics.First();
TextSpan diagnosticSpan = diagnostic.Location.SourceSpan;

// Find the type declaration identified by the diagnostic.
ParameterListSyntax? badArgumentListSyntax = root.FindToken(diagnosticSpan.Start)
.Parent?
SyntaxNode? node = root.FindToken(diagnosticSpan.Start).Parent;

// Try parenthesized lambda path first (diagnostic on ParameterListSyntax).
ParameterListSyntax? badArgumentListSyntax = node?
.AncestorsAndSelf()
.OfType<ParameterListSyntax>()
.FirstOrDefault();

if (badArgumentListSyntax is null)
if (badArgumentListSyntax is not null)
{
context.RegisterCodeFix(
CodeAction.Create(
FixTitle,
cancellationToken => FixParenthesizedCallbackSignatureAsync(root, context.Document, badArgumentListSyntax, cancellationToken),
FixTitle),
diagnostic);
return;
}

// Register a code action that will invoke the fix.
context.RegisterCodeFix(
CodeAction.Create(
"Fix Moq callback signature",
cancellationToken => FixCallbackSignatureAsync(root, context.Document, badArgumentListSyntax, cancellationToken),
"Fix Moq callback signature"),
diagnostic);
// SimpleLambdaExpressionSyntax nodes never contain a ParameterListSyntax,
// so the ancestor search above intentionally falls through to this path.
SimpleLambdaExpressionSyntax? simpleLambda = node?
.AncestorsAndSelf()
.OfType<SimpleLambdaExpressionSyntax>()
.FirstOrDefault();

if (simpleLambda is not null && !IsInsideDelegateConstructor(simpleLambda))
{
context.RegisterCodeFix(
CodeAction.Create(
FixTitle,
cancellationToken => FixSimpleLambdaCallbackSignatureAsync(root, context.Document, simpleLambda, cancellationToken),
FixTitle),
diagnostic);
}
}

private static async Task<Document> FixCallbackSignatureAsync(SyntaxNode root, Document document, ParameterListSyntax? oldParameters, CancellationToken cancellationToken)
private static async Task<Document> FixParenthesizedCallbackSignatureAsync(SyntaxNode root, Document document, ParameterListSyntax oldParameters, CancellationToken cancellationToken)

Check notice on line 73 in src/CodeFixes/CallbackSignatureShouldMatchMockedMethodFixer.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/CodeFixes/CallbackSignatureShouldMatchMockedMethodFixer.cs#L73

Remove the 'Async' suffix to the name of this method.
{
SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
if (IsInsideDelegateConstructor(oldParameters))
{
return document;
}

if (semanticModel is null)
ParameterListSyntax? newParameters = await ResolveNewParameterListAsync(document, oldParameters, oldParameters.SpanStart, oldParameters.Parameters, cancellationToken).ConfigureAwait(false);
if (newParameters is null)
{
return document;
}

MoqKnownSymbols knownSymbols = new(semanticModel.Compilation);
SyntaxNode newRoot = root.ReplaceNode(oldParameters, newParameters);
return document.WithSyntaxRoot(newRoot);
}

if (oldParameters?.Parent?.Parent?.Parent?.Parent is not InvocationExpressionSyntax callbackInvocation)
private static async Task<Document> FixSimpleLambdaCallbackSignatureAsync(SyntaxNode root, Document document, SimpleLambdaExpressionSyntax simpleLambda, CancellationToken cancellationToken)

Check notice on line 90 in src/CodeFixes/CallbackSignatureShouldMatchMockedMethodFixer.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/CodeFixes/CallbackSignatureShouldMatchMockedMethodFixer.cs#L90

Remove the 'Async' suffix to the name of this method.
{
if (IsInsideDelegateConstructor(simpleLambda))
{
return document;
}

SeparatedSyntaxList<ParameterSyntax> originalParams = SyntaxFactory.SeparatedList(new[] { simpleLambda.Parameter });
ParameterListSyntax? newParameters = await ResolveNewParameterListAsync(document, simpleLambda, simpleLambda.SpanStart, originalParams, cancellationToken).ConfigureAwait(false);
if (newParameters is null)
{
return document;
}

ParenthesizedLambdaExpressionSyntax parenthesizedLambda = SyntaxFactory.ParenthesizedLambdaExpression(
simpleLambda.AsyncKeyword,
newParameters,
simpleLambda.ArrowToken,
simpleLambda.Block,
simpleLambda.ExpressionBody)
.WithTriviaFrom(simpleLambda);

SyntaxNode newRoot = root.ReplaceNode(simpleLambda, parenthesizedLambda);
return document.WithSyntaxRoot(newRoot);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private static async Task<ParameterListSyntax?> ResolveNewParameterListAsync(Document document, SyntaxNode lambdaNode, int position, SeparatedSyntaxList<ParameterSyntax>? originalParameters, CancellationToken cancellationToken)

Check notice on line 116 in src/CodeFixes/CallbackSignatureShouldMatchMockedMethodFixer.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/CodeFixes/CallbackSignatureShouldMatchMockedMethodFixer.cs#L116

Remove the 'Async' suffix to the name of this method.
{
SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
if (semanticModel is null)
{
return null;
}

MoqKnownSymbols knownSymbols = new(semanticModel.Compilation);

InvocationExpressionSyntax? callbackInvocation = lambdaNode
.Ancestors()
.OfType<InvocationExpressionSyntax>()
.FirstOrDefault();

if (callbackInvocation is null)
{
return null;
}

IMethodSymbol? mockedMethod = FindSingleMockedMethod(semanticModel, knownSymbols, callbackInvocation, cancellationToken);
if (mockedMethod is null)
{
return null;
}

return BuildParameterList(semanticModel, mockedMethod, position, originalParameters);
}

private static bool IsInsideDelegateConstructor(SyntaxNode node)
{
return node
.Ancestors()
.OfType<ObjectCreationExpressionSyntax>()
.Any();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

private static IMethodSymbol? FindSingleMockedMethod(SemanticModel semanticModel, MoqKnownSymbols knownSymbols, InvocationExpressionSyntax callbackInvocation, CancellationToken cancellationToken)
{
InvocationExpressionSyntax? setupMethodInvocation = semanticModel.FindSetupMethodFromCallbackInvocation(knownSymbols, callbackInvocation, cancellationToken);
if (setupMethodInvocation is null)
{
return document;
return null;
}

IMethodSymbol[] matchingMockedMethods = semanticModel.GetAllMatchingMockedMethodSymbolsFromSetupMethodInvocation(setupMethodInvocation).ToArray();
// Short-circuit: we only need to know if there is exactly one match.
IMethodSymbol[] matchingMockedMethods = semanticModel.GetAllMatchingMockedMethodSymbolsFromSetupMethodInvocation(setupMethodInvocation).Take(2).ToArray();

if (matchingMockedMethods.Length != 1)
{
return document;
return null;
}

ParameterListSyntax newParameters = SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(matchingMockedMethods[0].Parameters.Select(
parameterSymbol =>
return matchingMockedMethods[0];
}

private static ParameterListSyntax BuildParameterList(SemanticModel semanticModel, IMethodSymbol mockedMethod, int position, SeparatedSyntaxList<ParameterSyntax>? originalParameters = null)
{
return SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(mockedMethod.Parameters.Select(
(parameterSymbol, index) =>
{
TypeSyntax type = SyntaxFactory.ParseTypeName(parameterSymbol.Type.ToMinimalDisplayString(semanticModel, oldParameters.SpanStart));
TypeSyntax type = SyntaxFactory.ParseTypeName(parameterSymbol.Type.ToMinimalDisplayString(semanticModel, position));
SyntaxTokenList modifiers = GetParameterModifiers(parameterSymbol.RefKind);
return SyntaxFactory.Parameter(default, modifiers, type, SyntaxFactory.Identifier(parameterSymbol.Name), null);
})));

SyntaxNode newRoot = root.ReplaceNode(oldParameters, newParameters);
return document.WithSyntaxRoot(newRoot);
string name = originalParameters.HasValue && index < originalParameters.Value.Count
? originalParameters.Value[index].Identifier.ValueText
: parameterSymbol.Name;

SyntaxToken identifier = SyntaxFactory.Identifier(name);

return SyntaxFactory.Parameter(default, modifiers, type, identifier, null);
})));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

private static SyntaxTokenList GetParameterModifiers(RefKind refKind)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ public static IEnumerable<object[]> CallbackValidationData()

// Explicitly typed lambda with correct parameter type (exercises GetDeclaredSymbol path)
["""new Mock<IFoo>().Setup(x => x.DoWork("test")).Callback((string x) => { });"""],

// Simple lambda in delegate constructor with correct type (issue #1012)
["""new Mock<IFoo>().Setup(x => x.DoWork("test")).Callback(new Action<string>(x => { }));"""],

// Simple lambda in delegate constructor with correct type using Returns (issue #1012)
["""new Mock<IFoo>().Setup(x => x.DoWork("test")).Returns(new Func<string, int>(x => 42));"""],
}.WithNamespaces().WithMoqReferenceAssemblyGroups();

// Invalid patterns that SHOULD trigger the analyzer
Expand All @@ -76,6 +82,12 @@ public static IEnumerable<object[]> CallbackValidationData()

// Explicitly typed lambda with wrong parameter type (exercises semantic resolution mismatch)
["""new Mock<IFoo>().Setup(x => x.DoWork("test")).Callback(({|Moq1100:DateTime wrongParam|}) => { });"""],

// Simple lambda in delegate constructor with wrong type (issue #1012)
["""new Mock<IFoo>().Setup(x => x.DoWork("test")).Callback(new Action<int>({|Moq1100:x|} => { }));"""],

// Simple lambda in delegate constructor with argument count mismatch (issue #1012)
["""new Mock<IFoo>().Setup(x => x.ProcessMultiple(It.IsAny<int>(), It.IsAny<string>(), It.IsAny<DateTime>())).Callback(new Action<int>({|Moq1100:x|} => { }));"""],
Comment thread
rjmurillo-bot marked this conversation as resolved.
}.WithNamespaces().WithMoqReferenceAssemblyGroups();

return validPatterns.Concat(invalidPatterns);
Expand Down
Loading
Loading