Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 35 additions & 0 deletions src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,15 @@ private static bool HasReturnValueSpecification(
return true;
}

// When a non-Moq method (e.g., an extension method) appears in the chain,
// check if its return type implements a Moq fluent interface. A return type
// like IReturns<TMock, TResult> indicates the method wraps the setup
// configuration internally and preserves the fluent chain.
if (IsFluentChainWrapperMethod(symbolInfo, knownSymbols))
{
return true;
}

Comment thread
rjmurillo marked this conversation as resolved.
current = memberAccess.Parent as InvocationExpressionSyntax;
}

Expand Down Expand Up @@ -229,4 +238,30 @@ private static bool HasReturnValueSymbol(SymbolInfo symbolInfo, MoqKnownSymbols
.OfType<IMethodSymbol>()
.Any(method => method.IsMoqReturnValueSpecificationMethod(knownSymbols));
}

/// <summary>
/// Determines whether a method in the chain is a wrapper (e.g., an extension method)
/// whose return type implements a Moq fluent interface. Such methods wrap the setup
/// configuration internally (calling Returns/Throws inside) and return the fluent chain.
/// </summary>
private static bool IsFluentChainWrapperMethod(SymbolInfo symbolInfo, MoqKnownSymbols knownSymbols)
{
IMethodSymbol? method = symbolInfo.Symbol as IMethodSymbol
?? symbolInfo.CandidateSymbols.OfType<IMethodSymbol>().FirstOrDefault();

if (method == null)
{
return false;
}

// Skip known Moq methods; they are already handled by HasReturnValueSymbol
// and IsKnownReturnValueMethodName. This check targets user-defined wrappers.
if (method.IsMoqReturnValueSpecificationMethod(knownSymbols)
|| method.IsMoqCallbackMethod(knownSymbols))
{
return false;
Comment thread
rjmurillo marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.

return method.ReturnType.ImplementsMoqFluentInterface(knownSymbols);
}
Comment thread
rjmurillo marked this conversation as resolved.
}
68 changes: 68 additions & 0 deletions src/Common/ISymbolExtensions.Moq.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,24 @@
IsConcreteSetupPhraseRaisesMethod(symbol, knownSymbols);
}

/// <summary>
/// Determines whether a type symbol implements any Moq fluent interface from the
/// <c>Moq.Language</c> or <c>Moq.Language.Flow</c> namespaces. Used to detect
/// wrapper methods (e.g., extension methods) that internally configure the setup
/// chain and return a Moq fluent type.
/// </summary>
/// <param name="typeSymbol">The return type of the method to check.</param>
/// <param name="knownSymbols">Known Moq symbols resolved from the compilation.</param>
/// <returns>
/// <see langword="true"/> if the type implements or is a Moq fluent interface;
/// otherwise, <see langword="false"/>.
/// </returns>
internal static bool ImplementsMoqFluentInterface(this ITypeSymbol typeSymbol, MoqKnownSymbols knownSymbols)
{
return IsMoqFluentType(typeSymbol, knownSymbols)
|| HasMoqFluentInterfaceInHierarchy(typeSymbol, knownSymbols);
}

/// <summary>
/// Checks if the symbol is a Raises method from ISetup / ISetupPhrase interfaces.
/// </summary>
Expand Down Expand Up @@ -265,4 +283,54 @@
return genericType != null &&
SymbolEqualityComparer.Default.Equals(propertySymbol.ContainingType.OriginalDefinition, genericType);
}

/// <summary>
/// Checks if the type itself is a known Moq fluent interface.
/// </summary>
private static bool IsMoqFluentType(ITypeSymbol typeSymbol, MoqKnownSymbols knownSymbols)
{
if (typeSymbol is not INamedTypeSymbol namedType)
{
return false;
}

INamedTypeSymbol original = namedType.IsGenericType ? namedType.ConstructedFrom : namedType;

return IsMatchingFluentSymbol(original, knownSymbols);
}

/// <summary>
/// Walks the AllInterfaces list to find any Moq fluent interface in the type hierarchy.
/// </summary>
private static bool HasMoqFluentInterfaceInHierarchy(ITypeSymbol typeSymbol, MoqKnownSymbols knownSymbols)
{
foreach (INamedTypeSymbol iface in typeSymbol.AllInterfaces)
{
INamedTypeSymbol original = iface.IsGenericType ? iface.ConstructedFrom : iface;

if (IsMatchingFluentSymbol(original, knownSymbols))
{
return true;
}
}

return false;
}

/// <summary>
/// Compares a type symbol against the set of known Moq fluent interface symbols.
/// </summary>
private static bool IsMatchingFluentSymbol(INamedTypeSymbol type, MoqKnownSymbols knownSymbols)

Check warning on line 323 in src/Common/ISymbolExtensions.Moq.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/Common/ISymbolExtensions.Moq.cs#L323

Method ISymbolExtensions::IsMatchingFluentSymbol has a cyclomatic complexity of 10 (limit is 8)
{
return SymbolEqualityComparer.Default.Equals(type, knownSymbols.IReturns)

Check notice on line 325 in src/Common/ISymbolExtensions.Moq.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/Common/ISymbolExtensions.Moq.cs#L325

Reduce the number of conditional operators (9) used in the expression (maximum allowed 3).
|| SymbolEqualityComparer.Default.Equals(type, knownSymbols.IReturns1)
|| SymbolEqualityComparer.Default.Equals(type, knownSymbols.IReturns2)
Comment thread
rjmurillo marked this conversation as resolved.
Outdated
|| SymbolEqualityComparer.Default.Equals(type, knownSymbols.IThrows)
|| SymbolEqualityComparer.Default.Equals(type, knownSymbols.ICallback)
|| SymbolEqualityComparer.Default.Equals(type, knownSymbols.ICallback1)
Comment thread
rjmurillo marked this conversation as resolved.
Outdated
|| SymbolEqualityComparer.Default.Equals(type, knownSymbols.ICallback2)
|| SymbolEqualityComparer.Default.Equals(type, knownSymbols.ISetup1)
Comment thread
rjmurillo marked this conversation as resolved.
|| SymbolEqualityComparer.Default.Equals(type, knownSymbols.ISetupGetter)
|| SymbolEqualityComparer.Default.Equals(type, knownSymbols.ISetupSetter);
}
Comment thread
rjmurillo marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,62 @@ public static IEnumerable<object[]> CustomReturnTypeMissingReturnValueTestData()
return data.WithNamespaces().WithMoqReferenceAssemblyGroups();
}

// Regression test data for https://github.com/rjmurillo/moq.analyzers/issues/1067
// Extension methods wrapping Returns/ReturnsAsync should suppress Moq1203.
public static IEnumerable<object[]> Issue1067_WrappedSetupTestData()
{
IEnumerable<object[]> data =
[

// Extension method returning IReturns<TMock, bool> that calls Returns internally
["""
var moq = new Mock<IFoo>(MockBehavior.Strict);
moq.Setup(x => x.DoSomething("test")).ReturnsTrue();
"""],

// Extension method returning IReturns<TMock, int> that calls Returns internally
["""
var moq = new Mock<IFoo>();
moq.Setup(x => x.GetValue()).ReturnsFortyTwo();
"""],

// Chained extension methods: Setup().ReturnsTrue() where ReturnsTrue returns IReturns
["""
new Mock<IFoo>().Setup(x => x.DoSomething("test")).ReturnsTrue();
"""],

// Extension method on async setup returning Task result wrapper
["""
var moq = new Mock<IFoo>();
moq.Setup(x => x.BarAsync()).ReturnsOneAsync();
"""],
];

return data.WithNamespaces().WithNewMoqReferenceAssemblyGroups();
Comment thread
rjmurillo marked this conversation as resolved.
Comment thread
rjmurillo marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
rjmurillo marked this conversation as resolved.
Comment thread
rjmurillo marked this conversation as resolved.

// Extension methods that do NOT return a Moq fluent interface should still trigger Moq1203.
public static IEnumerable<object[]> Issue1067_NonFluentExtensionTestData()
{
IEnumerable<object[]> data =
[

// Extension method returning void (should still flag)
["""
var moq = new Mock<IFoo>();
{|Moq1203:moq.Setup(x => x.DoSomething("test"))|}.LogSetup();
"""],

// Extension method returning unrelated type (should still flag)
["""
var moq = new Mock<IFoo>();
{|Moq1203:moq.Setup(x => x.GetValue())|}.ToDescription();
"""],
];

return data.WithNamespaces().WithNewMoqReferenceAssemblyGroups();
}

[Theory]
[MemberData(nameof(TestData))]
public async Task ShouldAnalyzeMethodSetupReturnValue(string referenceAssemblyGroup, string @namespace, string mock)
Expand Down Expand Up @@ -504,6 +560,20 @@ public async Task ShouldFlagSetupWithCustomReturnTypeMissingReturnValue(string r
await VerifyCustomSourceMockAsync(referenceAssemblyGroup, @namespace, mock, recordDeclaration, interfaceMethod);
}

[Theory]
[MemberData(nameof(Issue1067_WrappedSetupTestData))]
public async Task ShouldNotFlagSetupWrappedInFluentExtensionMethod(string referenceAssemblyGroup, string @namespace, string mock)
{
await VerifyWrappedSetupMockAsync(referenceAssemblyGroup, @namespace, mock);
}

[Theory]
[MemberData(nameof(Issue1067_NonFluentExtensionTestData))]
public async Task ShouldFlagSetupWithNonFluentExtensionMethod(string referenceAssemblyGroup, string @namespace, string mock)
{
await VerifyWrappedSetupMockAsync(referenceAssemblyGroup, @namespace, mock);
}

private static string BuildSource(string @namespace, string mock)
{
return $$"""
Expand Down Expand Up @@ -552,6 +622,70 @@ private void Test()
""";
}

private static string BuildWrappedSetupSource(string @namespace, string mock)
{
return $$"""
{{@namespace}}
using Moq.Language;
using Moq.Language.Flow;

public interface IFoo
{
bool DoSomething(string value);
int GetValue();
int Calculate(int a, int b);
Task<int> BarAsync();
void DoVoidMethod();
void ProcessData(string data);
string Name { get; set; }
}

public static class MoqExtensions
{
public static IReturns<TMock, bool> ReturnsTrue<TMock>(this IReturns<TMock, bool> mock)
where TMock : class
{
mock.Returns(true);
return mock;
}

public static IReturns<TMock, int> ReturnsFortyTwo<TMock>(this IReturns<TMock, int> mock)
where TMock : class
{
mock.Returns(42);
return mock;
}

public static IReturns<TMock, Task<int>> ReturnsOneAsync<TMock>(this IReturns<TMock, Task<int>> mock)
where TMock : class
{
mock.Returns(Task.FromResult(1));
return mock;
}

public static void LogSetup<TMock, TResult>(this ISetup<TMock, TResult> setup)
where TMock : class
{
// Does not configure return value, returns void
}

public static string ToDescription<TMock, TResult>(this ISetup<TMock, TResult> setup)
where TMock : class
{
return "description";
}
}

internal class UnitTest
{
private void Test()
{
{{mock}}
}
}
""";
}
Comment thread
rjmurillo marked this conversation as resolved.

private async Task VerifyMockAsync(string referenceAssemblyGroup, string @namespace, string mock)
{
string source = BuildSource(@namespace, mock);
Expand Down Expand Up @@ -582,4 +716,14 @@ await Verifier.VerifyAnalyzerAsync(
referenceAssemblyGroup,
CompilerDiagnostics.None).ConfigureAwait(false);
}

private async Task VerifyWrappedSetupMockAsync(string referenceAssemblyGroup, string @namespace, string mock)
{
string source = BuildWrappedSetupSource(@namespace, mock);
output.WriteLine(source);

await Verifier.VerifyAnalyzerAsync(
source,
referenceAssemblyGroup).ConfigureAwait(false);
}
}
Loading