diff --git a/Directory.Packages.props b/Directory.Packages.props index 80334e13..672bc235 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,7 @@ true true + diff --git a/Source/Moq.Analyzers/AsShouldBeUsedOnlyForInterfaceAnalyzer.cs b/Source/Moq.Analyzers/AsShouldBeUsedOnlyForInterfaceAnalyzer.cs index 34fcf7b3..d1edfa2a 100644 --- a/Source/Moq.Analyzers/AsShouldBeUsedOnlyForInterfaceAnalyzer.cs +++ b/Source/Moq.Analyzers/AsShouldBeUsedOnlyForInterfaceAnalyzer.cs @@ -3,6 +3,8 @@ namespace Moq.Analyzers; [DiagnosticAnalyzer(LanguageNames.CSharp)] public class AsShouldBeUsedOnlyForInterfaceAnalyzer : DiagnosticAnalyzer { + private static readonly MoqMethodDescriptorBase MoqAsMethodDescriptor = new MoqAsMethodDescriptor(); + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( Diagnostics.AsShouldBeUsedOnlyForInterfaceId, Diagnostics.AsShouldBeUsedOnlyForInterfaceTitle, @@ -22,20 +24,37 @@ public override void Initialize(AnalysisContext context) private static void Analyze(SyntaxNodeAnalysisContext context) { - InvocationExpressionSyntax? asInvocation = (InvocationExpressionSyntax)context.Node; + if (context.Node is not InvocationExpressionSyntax invocationExpression) + { + return; + } + + if (invocationExpression.Expression is not MemberAccessExpressionSyntax memberAccessSyntax) + { + return; + } + + if (!MoqAsMethodDescriptor.IsMatch(context.SemanticModel, memberAccessSyntax, context.CancellationToken)) + { + return; + } + + if (!memberAccessSyntax.Name.TryGetGenericArguments(out SeparatedSyntaxList typeArguments)) + { + return; + } + + if (typeArguments.Count != 1) + { + return; + } + + TypeSyntax typeArgument = typeArguments[0]; + SymbolInfo symbolInfo = context.SemanticModel.GetSymbolInfo(typeArgument, context.CancellationToken); - if (asInvocation.Expression is MemberAccessExpressionSyntax memberAccessExpression - && Helpers.IsMoqAsMethod(context.SemanticModel, memberAccessExpression) - && memberAccessExpression.Name is GenericNameSyntax genericName - && genericName.TypeArgumentList.Arguments.Count == 1) + if (symbolInfo.Symbol is ITypeSymbol { TypeKind: not TypeKind.Interface }) { - TypeSyntax? typeArgument = genericName.TypeArgumentList.Arguments[0]; - SymbolInfo symbolInfo = context.SemanticModel.GetSymbolInfo(typeArgument, context.CancellationToken); - if (symbolInfo.Symbol is ITypeSymbol typeSymbol && typeSymbol.TypeKind != TypeKind.Interface) - { - Diagnostic? diagnostic = Diagnostic.Create(Rule, typeArgument.GetLocation()); - context.ReportDiagnostic(diagnostic); - } + context.ReportDiagnostic(Diagnostic.Create(Rule, typeArgument.GetLocation())); } } } diff --git a/Source/Moq.Analyzers/CallbackSignatureShouldMatchMockedMethodAnalyzer.cs b/Source/Moq.Analyzers/CallbackSignatureShouldMatchMockedMethodAnalyzer.cs index da27c8af..2b294dae 100644 --- a/Source/Moq.Analyzers/CallbackSignatureShouldMatchMockedMethodAnalyzer.cs +++ b/Source/Moq.Analyzers/CallbackSignatureShouldMatchMockedMethodAnalyzer.cs @@ -43,7 +43,7 @@ private static void Analyze(SyntaxNodeAnalysisContext context) SeparatedSyntaxList lambdaParameters = callbackLambda.ParameterList.Parameters; if (lambdaParameters.Count == 0) return; - InvocationExpressionSyntax? setupInvocation = Helpers.FindSetupMethodFromCallbackInvocation(context.SemanticModel, callbackOrReturnsInvocation); + InvocationExpressionSyntax? setupInvocation = Helpers.FindSetupMethodFromCallbackInvocation(context.SemanticModel, callbackOrReturnsInvocation, context.CancellationToken); InvocationExpressionSyntax? mockedMethodInvocation = Helpers.FindMockedMethodInvocationFromSetupMethod(setupInvocation); if (mockedMethodInvocation == null) return; diff --git a/Source/Moq.Analyzers/CallbackSignatureShouldMatchMockedMethodCodeFix.cs b/Source/Moq.Analyzers/CallbackSignatureShouldMatchMockedMethodCodeFix.cs index d211dd98..5e88c879 100644 --- a/Source/Moq.Analyzers/CallbackSignatureShouldMatchMockedMethodCodeFix.cs +++ b/Source/Moq.Analyzers/CallbackSignatureShouldMatchMockedMethodCodeFix.cs @@ -64,7 +64,7 @@ private async Task FixCallbackSignatureAsync(SyntaxNode root, Document return document; } - InvocationExpressionSyntax? setupMethodInvocation = Helpers.FindSetupMethodFromCallbackInvocation(semanticModel, callbackInvocation); + InvocationExpressionSyntax? setupMethodInvocation = Helpers.FindSetupMethodFromCallbackInvocation(semanticModel, callbackInvocation, cancellationToken); Debug.Assert(setupMethodInvocation != null, nameof(setupMethodInvocation) + " != null"); IMethodSymbol[]? matchingMockedMethods = Helpers.GetAllMatchingMockedMethodSymbolsFromSetupMethodInvocation(semanticModel, setupMethodInvocation).ToArray(); diff --git a/Source/Moq.Analyzers/Helpers.cs b/Source/Moq.Analyzers/Helpers.cs index b942cc1c..ab0165c1 100644 --- a/Source/Moq.Analyzers/Helpers.cs +++ b/Source/Moq.Analyzers/Helpers.cs @@ -1,22 +1,14 @@ using System.Diagnostics; -using System.Text.RegularExpressions; namespace Moq.Analyzers; internal static class Helpers { - private static readonly MoqMethodDescriptor MoqSetupMethodDescriptor = new("Setup", new Regex("^Moq\\.Mock<.*>\\.Setup\\.*")); + private static readonly MoqMethodDescriptorBase MoqSetupMethodDescriptor = new MoqSetupMethodDescriptor(); - private static readonly MoqMethodDescriptor MoqAsMethodDescriptor = new("As", new Regex("^Moq\\.Mock\\.As<\\.*"), isGeneric: true); - - internal static bool IsMoqSetupMethod(SemanticModel semanticModel, MemberAccessExpressionSyntax method) - { - return MoqSetupMethodDescriptor.IsMoqMethod(semanticModel, method); - } - - internal static bool IsMoqAsMethod(SemanticModel semanticModel, MemberAccessExpressionSyntax method) + internal static bool IsMoqSetupMethod(SemanticModel semanticModel, MemberAccessExpressionSyntax method, CancellationToken cancellationToken) { - return MoqAsMethodDescriptor.IsMoqMethod(semanticModel, method); + return MoqSetupMethodDescriptor.IsMatch(semanticModel, method, cancellationToken); } internal static bool IsCallbackOrReturnInvocation(SemanticModel semanticModel, InvocationExpressionSyntax callbackOrReturnsInvocation) @@ -48,12 +40,12 @@ internal static bool IsCallbackOrReturnInvocation(SemanticModel semanticModel, I }; } - internal static InvocationExpressionSyntax? FindSetupMethodFromCallbackInvocation(SemanticModel semanticModel, ExpressionSyntax expression) + internal static InvocationExpressionSyntax? FindSetupMethodFromCallbackInvocation(SemanticModel semanticModel, ExpressionSyntax expression, CancellationToken cancellationToken) { InvocationExpressionSyntax? invocation = expression as InvocationExpressionSyntax; if (invocation?.Expression is not MemberAccessExpressionSyntax method) return null; - if (IsMoqSetupMethod(semanticModel, method)) return invocation; - return FindSetupMethodFromCallbackInvocation(semanticModel, method.Expression); + if (IsMoqSetupMethod(semanticModel, method, cancellationToken)) return invocation; + return FindSetupMethodFromCallbackInvocation(semanticModel, method.Expression, cancellationToken); } internal static InvocationExpressionSyntax? FindMockedMethodInvocationFromSetupMethod(InvocationExpressionSyntax? setupInvocation) diff --git a/Source/Moq.Analyzers/MoqAsMethodDescriptor.cs b/Source/Moq.Analyzers/MoqAsMethodDescriptor.cs new file mode 100644 index 00000000..afe70d77 --- /dev/null +++ b/Source/Moq.Analyzers/MoqAsMethodDescriptor.cs @@ -0,0 +1,32 @@ +namespace Moq.Analyzers; + +/// +/// A class that, given a and a , determines if +/// it is a call to the Moq `Mock.As()` method. +/// +internal class MoqAsMethodDescriptor : MoqMethodDescriptorBase +{ + private const string MethodName = "As"; + + public override bool IsMatch(SemanticModel semanticModel, MemberAccessExpressionSyntax memberAccessSyntax, CancellationToken cancellationToken) + { + if (!IsFastMatch(memberAccessSyntax, MethodName.AsSpan())) + { + return false; + } + + ISymbol? symbol = semanticModel.GetSymbolInfo(memberAccessSyntax, cancellationToken).Symbol; + + if (symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + if (!IsContainedInMockType(methodSymbol)) + { + return false; + } + + return methodSymbol.Name.AsSpan().SequenceEqual(MethodName.AsSpan()) && methodSymbol.IsGenericMethod; + } +} diff --git a/Source/Moq.Analyzers/MoqMethodDescriptor.cs b/Source/Moq.Analyzers/MoqMethodDescriptor.cs deleted file mode 100644 index 70289697..00000000 --- a/Source/Moq.Analyzers/MoqMethodDescriptor.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Diagnostics; -using System.Text.RegularExpressions; - -namespace Moq.Analyzers; - -internal class MoqMethodDescriptor -{ - private readonly bool _isGeneric; - - public MoqMethodDescriptor(string shortMethodName, Regex fullMethodNamePattern, bool isGeneric = false) - { - _isGeneric = isGeneric; - ShortMethodName = shortMethodName; - FullMethodNamePattern = fullMethodNamePattern; - } - - private string ShortMethodName { get; } - - private Regex FullMethodNamePattern { get; } - - public bool IsMoqMethod(SemanticModel semanticModel, MemberAccessExpressionSyntax? method) - { - string? methodName = method?.Name.ToString(); - - Debug.Assert(!string.IsNullOrEmpty(methodName), nameof(methodName) + " != null or empty"); - - if (string.IsNullOrEmpty(methodName)) return false; - - // First fast check before walking semantic model - if (!DoesShortMethodMatch(methodName!)) return false; - - Debug.Assert(method != null, nameof(method) + " != null"); - - if (method == null) - { - return false; - } - - SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(method); - return symbolInfo.CandidateReason switch - { - CandidateReason.OverloadResolutionFailure => symbolInfo.CandidateSymbols.OfType().Any(s => FullMethodNamePattern.IsMatch(s.ToString())), - CandidateReason.None => symbolInfo.Symbol is IMethodSymbol && - FullMethodNamePattern.IsMatch(symbolInfo.Symbol.ToString()), - _ => false, - }; - } - - private bool DoesShortMethodMatch(string methodName) - { - if (_isGeneric) - { - return methodName.StartsWith($"{ShortMethodName}<", StringComparison.Ordinal); - } - - return string.Equals(methodName, ShortMethodName, StringComparison.Ordinal); - } -} diff --git a/Source/Moq.Analyzers/MoqMethodDescriptorBase.cs b/Source/Moq.Analyzers/MoqMethodDescriptorBase.cs new file mode 100644 index 00000000..cc8bc090 --- /dev/null +++ b/Source/Moq.Analyzers/MoqMethodDescriptorBase.cs @@ -0,0 +1,38 @@ +namespace Moq.Analyzers; + +/// +/// A base that that provides common functionality for identifying if a given +/// is a specific Moq method. +/// +/// +/// Currently the abstract method +/// is specific to because that's the only type of syntax in use. I expect we'll need +/// to loosen this restriction if we start using other types of syntax. +/// +internal abstract class MoqMethodDescriptorBase +{ + private const string ContainingNamespace = "Moq"; + private const string ContainingType = "Mock"; + + public abstract bool IsMatch(SemanticModel semanticModel, MemberAccessExpressionSyntax memberAccessSyntax, CancellationToken cancellationToken); + + protected static bool IsFastMatch(MemberAccessExpressionSyntax memberAccessSyntax, ReadOnlySpan methodName) + { + return memberAccessSyntax.Name.Identifier.Text.AsSpan().SequenceEqual(methodName); + } + + protected static bool IsContainedInMockType(IMethodSymbol methodSymbol) + { + return IsInMoqNamespace(methodSymbol) && IsInMockType(methodSymbol); + } + + private static bool IsInMoqNamespace(ISymbol symbol) + { + return symbol.ContainingNamespace.Name.AsSpan().SequenceEqual(ContainingNamespace.AsSpan()); + } + + private static bool IsInMockType(ISymbol symbol) + { + return symbol.ContainingType.Name.AsSpan().SequenceEqual(ContainingType.AsSpan()); + } +} diff --git a/Source/Moq.Analyzers/MoqSetupMethodDescriptor.cs b/Source/Moq.Analyzers/MoqSetupMethodDescriptor.cs new file mode 100644 index 00000000..fe42697b --- /dev/null +++ b/Source/Moq.Analyzers/MoqSetupMethodDescriptor.cs @@ -0,0 +1,32 @@ +namespace Moq.Analyzers; + +/// +/// A class that, given a and a , determines if +/// it is a call to the Moq `Mock.Setup()` method. +/// +internal class MoqSetupMethodDescriptor : MoqMethodDescriptorBase +{ + private const string MethodName = "Setup"; + + public override bool IsMatch(SemanticModel semanticModel, MemberAccessExpressionSyntax memberAccessSyntax, CancellationToken cancellationToken) + { + if (!IsFastMatch(memberAccessSyntax, MethodName.AsSpan())) + { + return false; + } + + ISymbol? symbol = semanticModel.GetSymbolInfo(memberAccessSyntax, cancellationToken).Symbol; + + if (symbol is not IMethodSymbol methodSymbol) + { + return false; + } + + if (!IsContainedInMockType(methodSymbol)) + { + return false; + } + + return methodSymbol.Name.AsSpan().SequenceEqual(MethodName.AsSpan()) && methodSymbol.IsGenericMethod; + } +} diff --git a/Source/Moq.Analyzers/SetupShouldBeUsedOnlyForOverridableMembersAnalyzer.cs b/Source/Moq.Analyzers/SetupShouldBeUsedOnlyForOverridableMembersAnalyzer.cs index d829e24c..d6971626 100644 --- a/Source/Moq.Analyzers/SetupShouldBeUsedOnlyForOverridableMembersAnalyzer.cs +++ b/Source/Moq.Analyzers/SetupShouldBeUsedOnlyForOverridableMembersAnalyzer.cs @@ -24,7 +24,7 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { InvocationExpressionSyntax? setupInvocation = (InvocationExpressionSyntax)context.Node; - if (setupInvocation.Expression is MemberAccessExpressionSyntax memberAccessExpression && Helpers.IsMoqSetupMethod(context.SemanticModel, memberAccessExpression)) + if (setupInvocation.Expression is MemberAccessExpressionSyntax memberAccessExpression && Helpers.IsMoqSetupMethod(context.SemanticModel, memberAccessExpression, context.CancellationToken)) { ExpressionSyntax? mockedMemberExpression = Helpers.FindMockedMemberExpressionFromSetupMethod(setupInvocation); if (mockedMemberExpression == null) diff --git a/Source/Moq.Analyzers/SetupShouldNotIncludeAsyncResultAnalyzer.cs b/Source/Moq.Analyzers/SetupShouldNotIncludeAsyncResultAnalyzer.cs index 25b81115..cc294de5 100644 --- a/Source/Moq.Analyzers/SetupShouldNotIncludeAsyncResultAnalyzer.cs +++ b/Source/Moq.Analyzers/SetupShouldNotIncludeAsyncResultAnalyzer.cs @@ -24,7 +24,7 @@ private static void Analyze(SyntaxNodeAnalysisContext context) { InvocationExpressionSyntax? setupInvocation = (InvocationExpressionSyntax)context.Node; - if (setupInvocation.Expression is MemberAccessExpressionSyntax memberAccessExpression && Helpers.IsMoqSetupMethod(context.SemanticModel, memberAccessExpression)) + if (setupInvocation.Expression is MemberAccessExpressionSyntax memberAccessExpression && Helpers.IsMoqSetupMethod(context.SemanticModel, memberAccessExpression, context.CancellationToken)) { ExpressionSyntax? mockedMemberExpression = Helpers.FindMockedMemberExpressionFromSetupMethod(setupInvocation); if (mockedMemberExpression == null) diff --git a/Source/Moq.Analyzers/SyntaxExtensions.cs b/Source/Moq.Analyzers/SyntaxExtensions.cs new file mode 100644 index 00000000..9099f12e --- /dev/null +++ b/Source/Moq.Analyzers/SyntaxExtensions.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Moq.Analyzers; + +/// +/// Extensions methods for s. +/// +internal static class SyntaxExtensions +{ + /// + /// Tries to get the generic arguments of a given . + /// + /// The syntax to inspect. + /// The collection of elements on the . + /// if has generic / type parameters; otherwise. + /// + /// x.As<ISampleInterface>() returns and will contain ISampleInterface. + /// + /// + /// x.As() returns and will be empty. + /// + public static bool TryGetGenericArguments(this NameSyntax syntax, [NotNullWhen(true)] out SeparatedSyntaxList typeArguments) + { + if (syntax is GenericNameSyntax genericName) + { + typeArguments = genericName.TypeArgumentList.Arguments; + return true; + } + + typeArguments = default; + return false; + } +} diff --git a/build/targets/compiler/Compiler.props b/build/targets/compiler/Compiler.props index 2cab0aac..7f9eb03a 100644 --- a/build/targets/compiler/Compiler.props +++ b/build/targets/compiler/Compiler.props @@ -4,4 +4,11 @@ 12.0 enable + + + + all + runtime; build; native; contentfiles; analyzers + + diff --git a/build/targets/compiler/Packages.props b/build/targets/compiler/Packages.props new file mode 100644 index 00000000..e1375feb --- /dev/null +++ b/build/targets/compiler/Packages.props @@ -0,0 +1,5 @@ + + + + +