rjmurillo/moq.analyzers

View on GitHub
src/Analyzers/ConstructorArgumentsShouldMatchAnalyzer.cs

Summary

Maintainability
D
1 day
Test Coverage
using System.Diagnostics.CodeAnalysis;

namespace Moq.Analyzers;

/// <summary>
/// A diagnostic analyzer that ensures the arguments provided to the constructor
/// of a mocked object match an existing constructor of the class being mocked.
/// </summary>
/// <remarks>
/// This analyzer helps catch runtime failures related to constructor mismatches in Moq-based unit tests.
/// </remarks>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ConstructorArgumentsShouldMatchAnalyzer : DiagnosticAnalyzer
{
    private static readonly DiagnosticDescriptor ClassMustHaveMatchingConstructor = new(
        DiagnosticIds.NoMatchingConstructorRuleId,
        "Mock<T> construction must call an existing type constructor",
        "Could not find a matching constructor for arguments {0}",
        DiagnosticCategory.Moq,
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Parameters provided into mock do not match any existing constructors.",
        helpLinkUri: $"https://github.com/rjmurillo/moq.analyzers/blob/{ThisAssembly.GitCommitId}/docs/rules/{DiagnosticIds.NoMatchingConstructorRuleId}.md");

    private static readonly DiagnosticDescriptor InterfaceMustNotHaveConstructorParameters = new(
        DiagnosticIds.NoConstructorArgumentsForInterfaceMockRuleId,
        "Mock<T> construction must not specify parameters for interfaces",
        "Mocked interface cannot have constructor parameters {0}",
        DiagnosticCategory.Moq,
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Mock of interface cannot contain constructor parameters.",
        helpLinkUri: $"https://github.com/rjmurillo/moq.analyzers/blob/{ThisAssembly.GitCommitId}/docs/rules/{DiagnosticIds.NoConstructorArgumentsForInterfaceMockRuleId}.md");

    /// <inheritdoc />
    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = ImmutableArray.Create(ClassMustHaveMatchingConstructor, InterfaceMustNotHaveConstructorParameters);

    /// <inheritdoc />
    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        context.RegisterCompilationStartAction(AnalyzeCompilation);
    }

    /// <summary>
    /// Gets the <see cref="GenericNameSyntax"/> from a <see cref="TypeSyntax"/>.
    /// </summary>
    /// <param name="typeSyntax">The type syntax.</param>
    /// <returns>A <see cref="GetGenericNameSyntax"/> when the <paramref name="typeSyntax"/>
    /// is either <see cref="GenericNameSyntax"/> or <see cref="QualifiedNameSyntax"/>; otherwise,
    /// <see langword="null" />.</returns>
    private static GenericNameSyntax? GetGenericNameSyntax(TypeSyntax typeSyntax)
    {
        // REVIEW: Switch and ifs are equal in this case?
        // The switch expression adds more instructions to do the same, so stick with ifs
        if (typeSyntax is GenericNameSyntax genericNameSyntax)
        {
            return genericNameSyntax;
        }

        if (typeSyntax is QualifiedNameSyntax qualifiedNameSyntax)
        {
            return qualifiedNameSyntax.Right as GenericNameSyntax;
        }

        return null;
    }

    private static bool IsExpressionMockBehavior(SyntaxNodeAnalysisContext context, MoqKnownSymbols knownSymbols, ExpressionSyntax? expression)
    {
        if (expression is null)
        {
            return false;
        }

        SymbolInfo symbolInfo = context.SemanticModel.GetSymbolInfo(expression, context.CancellationToken);

        if (symbolInfo.Symbol is null)
        {
            return false;
        }

        ISymbol targetSymbol = symbolInfo.Symbol;
        if (symbolInfo.Symbol is IParameterSymbol parameterSymbol)
        {
            targetSymbol = parameterSymbol.Type;
        }
        else if (symbolInfo.Symbol is ILocalSymbol localSymbol)
        {
            targetSymbol = localSymbol.Type;
        }
        else if (symbolInfo.Symbol is IFieldSymbol fieldSymbol)
        {
            targetSymbol = fieldSymbol.Type;
        }

        return targetSymbol.IsInstanceOf(knownSymbols.MockBehavior);
    }

    private static bool IsFirstArgumentMockBehavior(SyntaxNodeAnalysisContext context, MoqKnownSymbols knownSymbols, ArgumentListSyntax? argumentList)
    {
        ExpressionSyntax? expression = argumentList?.Arguments[0].Expression;

        return IsExpressionMockBehavior(context, knownSymbols, expression);
    }

    private static void VerifyDelegateMockAttempt(
    SyntaxNodeAnalysisContext context,
    ArgumentListSyntax? argumentList,
    ArgumentSyntax[] arguments)
    {
        if (arguments.Length == 0)
        {
            return;
        }

        Diagnostic? diagnostic = argumentList?.CreateDiagnostic(ClassMustHaveMatchingConstructor, argumentList);
        if (diagnostic != null)
        {
            context.ReportDiagnostic(diagnostic);
        }
    }

    private static void VerifyInterfaceMockAttempt(
        SyntaxNodeAnalysisContext context,
        ArgumentListSyntax? argumentList,
        ArgumentSyntax[] arguments)
    {
        // Interfaces and delegates don't have ctors, so bail out early
        if (arguments.Length == 0)
        {
            return;
        }

        Diagnostic? diagnostic = argumentList?.CreateDiagnostic(InterfaceMustNotHaveConstructorParameters, argumentList);
        if (diagnostic != null)
        {
            context.ReportDiagnostic(diagnostic);
        }
    }

    private static void AnalyzeCompilation(CompilationStartAnalysisContext context)
    {
        context.CancellationToken.ThrowIfCancellationRequested();

        if (context.Compilation.Options.IsAnalyzerSuppressed(InterfaceMustNotHaveConstructorParameters)
            && context.Compilation.Options.IsAnalyzerSuppressed(ClassMustHaveMatchingConstructor))
        {
            return;
        }

        MoqKnownSymbols knownSymbols = new(context.Compilation);

        // We're interested in the few ways to create mocks:
        //  - new Mock<T>()
        //  - Mock.Of<T>()
        //  - MockRepository.Create<T>()
        //
        // Ensure Moq is referenced in the compilation
        if (!knownSymbols.IsMockReferenced())
        {
            return;
        }

        // These are for classes
        context.RegisterSyntaxNodeAction(context => AnalyzeNewObject(context, knownSymbols), SyntaxKind.ObjectCreationExpression);
        context.RegisterSyntaxNodeAction(context => AnalyzeInstanceCall(context, knownSymbols), SyntaxKind.InvocationExpression);
    }

    private static void AnalyzeInstanceCall(SyntaxNodeAnalysisContext context, MoqKnownSymbols knownSymbols)
    {
        InvocationExpressionSyntax invocationExpressionSyntax = (InvocationExpressionSyntax)context.Node;

        AnalyzeInvocation(context, knownSymbols, invocationExpressionSyntax);
    }

    private static void AnalyzeInvocation(
        SyntaxNodeAnalysisContext context,
        MoqKnownSymbols knownSymbols,
        InvocationExpressionSyntax invocationExpressionSyntax)
    {
        bool hasReturnedMock = true;
        bool hasMockBehavior = true;
        SymbolInfo symbol = context.SemanticModel.GetSymbolInfo(invocationExpressionSyntax, context.CancellationToken);

        if (symbol.Symbol is not IMethodSymbol method)
        {
            return;
        }

        if (!method.IsInstanceOf(knownSymbols.MockOf) && !method.IsInstanceOf(knownSymbols.MockRepositoryCreate))
        {
            return;
        }

        // We are calling MockRepository.Create<T> or Mock.Of<T>, determine which
        ArgumentListSyntax? argumentList = null;
        if (method.IsInstanceOf(knownSymbols.MockOf))
        {
            // Mock.Of<T> can specify condition for construction and MockBehavior, but
            // cannot specify constructor parameters
            //
            // The only parameters that can be passed are not relevant for verification
            // to just strip them
            hasReturnedMock = false;
        }
        else
        {
            argumentList = invocationExpressionSyntax.ArgumentList;
        }

        ITypeSymbol returnType = method.ReturnType;
        if (hasReturnedMock)
        {
            if (returnType is not INamedTypeSymbol { IsGenericType: true } typeSymbol)
            {
                return;
            }

            returnType = typeSymbol.TypeArguments[0];
        }

        VerifyMockAttempt(context, knownSymbols, returnType, argumentList, hasMockBehavior);
    }

    /// <summary>
    /// Analyzes when a Mock`1 object is created to verify the provided constructor arguments
    /// match an existing constructor of the mocked class.
    /// </summary>
    /// <param name="context">The context.</param>
    /// <param name="knownSymbols">The <see cref="MoqKnownSymbols"/> used to lookup symbols against Moq types.</param>
    private static void AnalyzeNewObject(SyntaxNodeAnalysisContext context, MoqKnownSymbols knownSymbols)
    {
        ObjectCreationExpressionSyntax newExpression = (ObjectCreationExpressionSyntax)context.Node;

        GenericNameSyntax? genericNameSyntax = GetGenericNameSyntax(newExpression.Type);
        if (genericNameSyntax == null)
        {
            return;
        }

        SymbolInfo symbolInfo = context.SemanticModel.GetSymbolInfo(newExpression, context.CancellationToken);

        if (!symbolInfo
            .Symbol?
            .IsInstanceOf(knownSymbols.Mock1?.Constructors ?? ImmutableArray<IMethodSymbol>.Empty)
            ?? false)
        {
            return;
        }

        if (symbolInfo.Symbol?.ContainingType is not INamedTypeSymbol { IsGenericType: true } typeSymbol)
        {
            return;
        }

        ITypeSymbol mockedClass = typeSymbol.TypeArguments[0];

        VerifyMockAttempt(context, knownSymbols, mockedClass, newExpression.ArgumentList, true);
    }

    /// <summary>
    /// Checks if the provided arguments match any of the constructors of the mocked class.
    /// </summary>
    /// <param name="constructors">The constructors.</param>
    /// <param name="arguments">The arguments.</param>
    /// <param name="context">The context.</param>
    /// <returns><c>true</c> if a suitable constructor was found; otherwise <c>false</c>. </returns>
    /// <remarks>Handles <see langword="params" /> and optional parameters.</remarks>
    [SuppressMessage("Design", "MA0051:Method is too long", Justification = "This should be refactored; suppressing for now to enable TreatWarningsAsErrors in CI.")]
    private static bool AnyConstructorsFound(
        IMethodSymbol[] constructors,
        ArgumentSyntax[] arguments,
        SyntaxNodeAnalysisContext context)
    {
        for (int constructorIndex = 0; constructorIndex < constructors.Length; constructorIndex++)
        {
            IMethodSymbol constructor = constructors[constructorIndex];
            bool hasParams = constructor.Parameters.Length > 0 && constructor.Parameters[^1].IsParams;
            int fixedParametersCount = hasParams ? constructor.Parameters.Length - 1 : constructor.Parameters.Length;
#pragma warning disable ECS0900 // Consider using an alternative implementation to avoid boxing and unboxing
            int requiredParameters = constructor.Parameters.Count(parameterSymbol => !parameterSymbol.IsOptional);
#pragma warning restore ECS0900 // Consider using an alternative implementation to avoid boxing and unboxing
            bool allParametersMatch = true;

            // Check if the number of arguments is valid considering params
            if ((arguments.Length < fixedParametersCount
                 || (!hasParams && arguments.Length > fixedParametersCount)
                 || (!hasParams && arguments.Length != fixedParametersCount))
                && requiredParameters != arguments.Length)
            {
                continue;
            }

            // There's a chance that there are optional parameters or a ctor that is only optional parameters
            if (arguments.Length <= requiredParameters
                && arguments.Length == 0
                && requiredParameters == 0
                && fixedParametersCount != 0)
            {
                return true;
            }

            // Check fixed parameters
            for (int parameterIndex = 0; parameterIndex < fixedParametersCount; parameterIndex++)
            {
                IParameterSymbol expectedParameter = constructor.Parameters[parameterIndex];

                if (parameterIndex < arguments.Length)
                {
                    ArgumentSyntax passedArgument = arguments[parameterIndex];

                    Conversion conversionType =
                        context.SemanticModel.ClassifyConversion(passedArgument.Expression, expectedParameter.Type);

                    if (!conversionType.Exists)
                    {
                        allParametersMatch = false;
                        break;
                    }
                }
            }

            // Check params parameters if applicable
            if (hasParams && allParametersMatch)
            {
                IParameterSymbol paramsParameter = constructor.Parameters[^1];
                ITypeSymbol paramsElementType = ((IArrayTypeSymbol)paramsParameter.Type).ElementType;

                for (int parameterIndex = fixedParametersCount; parameterIndex < arguments.Length; parameterIndex++)
                {
                    ArgumentSyntax passedArgument = arguments[parameterIndex];
                    Conversion conversionType = context.SemanticModel.ClassifyConversion(passedArgument.Expression, paramsElementType);

                    if (!conversionType.Exists)
                    {
                        allParametersMatch = false;
                        break;
                    }
                }
            }

            if (allParametersMatch)
            {
                return true;
            }
        }

        return false;
    }

    private static (bool IsEmpty, Location Location) ConstructorIsEmpty(
        IMethodSymbol[] constructors,
        ArgumentListSyntax? argumentList,
        SyntaxNodeAnalysisContext context)
    {
        Location location;

        if (argumentList != null)
        {
            location = argumentList.GetLocation();
        }
        else
        {
            location = context.Node.GetLocation();
        }

        return (constructors.Length == 0, location);
    }

    private static void VerifyMockAttempt(
                    SyntaxNodeAnalysisContext context,
                    MoqKnownSymbols knownSymbols,
                    ITypeSymbol mockedClass,
                    ArgumentListSyntax? argumentList,
                    bool hasMockBehavior)
    {
        if (mockedClass is IErrorTypeSymbol)
        {
            return;
        }

#pragma warning disable ECS0900 // Consider using an alternative implementation to avoid boxing and unboxing
        ArgumentSyntax[] arguments = argumentList?.Arguments.ToArray() ?? [];
#pragma warning restore ECS0900 // Consider using an alternative implementation to avoid boxing and unboxing

        if (hasMockBehavior && arguments.Length > 0 && IsFirstArgumentMockBehavior(context, knownSymbols, argumentList))
        {
            // They passed a mock behavior as the first argument; ignore as Moq swallows it
            arguments = arguments.RemoveAt(0);
        }

        switch (mockedClass.TypeKind)
        {
            case TypeKind.Interface:
                VerifyInterfaceMockAttempt(context, argumentList, arguments);
                break;

            case TypeKind.Delegate:
                // Interfaces and delegates don't have ctors, so bail out early
                VerifyDelegateMockAttempt(context, argumentList, arguments);
                break;

            case TypeKind.Class:
                // Now we're interested in the ctors for the mocked class
                VerifyClassMockAttempt(context, mockedClass, argumentList, arguments);

                break;
        }
    }

    private static void VerifyClassMockAttempt(
        SyntaxNodeAnalysisContext context,
        ITypeSymbol mockedClass,
        ArgumentListSyntax? argumentList,
        ArgumentSyntax[] arguments)
    {
        IMethodSymbol[] constructors = mockedClass
            .GetMembers()
            .OfType<IMethodSymbol>()
            .Where(methodSymbol => methodSymbol.IsConstructor())
            .ToArray();

        // Bail out early if there are no arguments on constructors or no constructors at all
        (bool IsEmpty, Location Location) constructorIsEmpty = ConstructorIsEmpty(constructors, argumentList, context);
        if (constructorIsEmpty.IsEmpty)
        {
            Diagnostic diagnostic = constructorIsEmpty.Location.CreateDiagnostic(ClassMustHaveMatchingConstructor, argumentList);
            context.ReportDiagnostic(diagnostic);
            return;
        }

        // We have constructors, now we need to check if the arguments match any of them
        if (!AnyConstructorsFound(constructors, arguments, context))
        {
            Diagnostic diagnostic = constructorIsEmpty.Location.CreateDiagnostic(ClassMustHaveMatchingConstructor, argumentList);
            context.ReportDiagnostic(diagnostic);
        }
    }
}