diff --git a/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs b/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs index 595d09d35..57b76748b 100644 --- a/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs +++ b/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs @@ -153,7 +153,7 @@ private static bool HasReturnValueSpecification( } SyntaxNode? current = setupSyntax; - while (current?.Parent is MemberAccessExpressionSyntax memberAccess) + while (current?.GetParentSkippingParentheses() is MemberAccessExpressionSyntax memberAccess) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Common/SyntaxNodeExtensions.cs b/src/Common/SyntaxNodeExtensions.cs index 39c6173e7..6b448a047 100644 --- a/src/Common/SyntaxNodeExtensions.cs +++ b/src/Common/SyntaxNodeExtensions.cs @@ -25,4 +25,21 @@ internal static class SyntaxNodeExtensions ? Location.Create(syntax.SyntaxTree, node.Span) : null; } + + /// + /// Returns the parent node, skipping any intermediate wrappers. + /// This handles cases like (mock.Setup(...)).Returns() where parentheses wrap an invocation. + /// + /// The node whose logical parent to find. + /// The first non-parenthesized ancestor, or if none exists. + internal static SyntaxNode? GetParentSkippingParentheses(this SyntaxNode node) + { + SyntaxNode? parent = node.Parent; + while (parent is ParenthesizedExpressionSyntax) + { + parent = parent.Parent; + } + + return parent; + } } diff --git a/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs b/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs index 866db27ab..cbfa9e61a 100644 --- a/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs +++ b/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs @@ -135,6 +135,55 @@ public static IEnumerable OverloadResolutionFailureWithDiagnosticTestD return data.WithNamespaces().WithMoqReferenceAssemblyGroups(); } + // Regression test data for https://github.com/rjmurillo/moq.analyzers/issues/887 + // Tests parenthesized Setup expressions that should correctly detect return value specs. + public static IEnumerable Issue887_ParenthesizedSetupTestData() + { + IEnumerable data = + [ + + // Parenthesized Setup with Returns should not trigger diagnostic + ["""(new Mock().Setup(x => x.GetValue())).Returns(42);"""], + + // Nested parentheses with Returns should not trigger diagnostic + ["""((new Mock().Setup(x => x.GetValue()))).Returns(42);"""], + + // Parenthesized Setup with Throws should not trigger diagnostic + ["""(new Mock().Setup(x => x.DoSomething("test"))).Throws();"""], + + // Parenthesized Setup with Callback chaining should not trigger diagnostic + ["""(new Mock().Setup(x => x.GetValue())).Callback(() => { }).Returns(42);"""], + + // Parentheses around intermediate chain node should not trigger diagnostic + ["""(new Mock().Setup(x => x.GetValue()).Callback(() => { })).Returns(42);"""], + + // Parenthesized Setup with ReturnsAsync should not trigger diagnostic + ["""(new Mock().Setup(x => x.BarAsync())).ReturnsAsync(1);"""], + ]; + + return data.WithNamespaces().WithMoqReferenceAssemblyGroups(); + } + + // Parenthesized Setup without return value spec should still trigger Moq1203. + // Uses discard assignment to keep expressions valid C# while preserving parentheses. + public static IEnumerable Issue887_ParenthesizedSetupWithDiagnosticTestData() + { + IEnumerable data = + [ + + // Parenthesized Setup WITHOUT return value spec should still trigger diagnostic + ["""_ = ({|Moq1203:new Mock().Setup(x => x.GetValue())|});"""], + + // Nested parentheses WITHOUT return value spec should still trigger diagnostic + ["""_ = (({|Moq1203:new Mock().Setup(x => x.GetValue())|}));"""], + + // Parenthesized async Setup WITHOUT return value spec should still trigger diagnostic + ["""_ = ({|Moq1203:new Mock().Setup(x => x.BarAsync())|});"""], + ]; + + return data.WithNamespaces().WithMoqReferenceAssemblyGroups(); + } + [Theory] [MemberData(nameof(TestData))] public async Task ShouldAnalyzeMethodSetupReturnValue(string referenceAssemblyGroup, string @namespace, string mock) @@ -156,6 +205,20 @@ public async Task ShouldFlagCallbackOnlySetupOnNewMoq(string referenceAssemblyGr await VerifyMockAsync(referenceAssemblyGroup, @namespace, mock); } + [Theory] + [MemberData(nameof(Issue887_ParenthesizedSetupTestData))] + public async Task ShouldHandleParenthesizedSetupExpressions(string referenceAssemblyGroup, string @namespace, string mock) + { + await VerifyMockAsync(referenceAssemblyGroup, @namespace, mock); + } + + [Theory] + [MemberData(nameof(Issue887_ParenthesizedSetupWithDiagnosticTestData))] + public async Task ShouldFlagParenthesizedSetupWithoutReturnValue(string referenceAssemblyGroup, string @namespace, string mock) + { + await VerifyMockAsync(referenceAssemblyGroup, @namespace, mock); + } + [Theory] [MemberData(nameof(DoppelgangerTestHelper.GetAllCustomMockData), MemberType = typeof(DoppelgangerTestHelper))] public async Task ShouldPassIfCustomMockClassIsUsed(string mockCode)