From 05c46522b2e906135420b82c270c14c658e7f8a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 30 Apr 2026 23:43:44 +0200 Subject: [PATCH] feat: add analyzer that detects NSubstitute usage --- Directory.Packages.props | 1 + .../AnalyzerReleases.Shipped.md | 7 +- .../NSubstituteAnalyzer.cs | 68 +++++++++ .../Resources.Designer.cs | 36 +++++ .../Resources.resx | 13 ++ Source/Mockolate.Migration.Analyzers/Rules.cs | 11 +- .../NSubstituteAnalyzerTests.cs | 141 ++++++++++++++++++ .../Verifiers/CSharpAnalyzerVerifier`1.cs | 1 + .../Verifiers/CSharpCodeFixVerifier`2.cs | 3 + 9 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 Source/Mockolate.Migration.Analyzers/NSubstituteAnalyzer.cs create mode 100644 Tests/Mockolate.Migration.Tests/NSubstituteAnalyzerTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index c677bba..6edba12 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,5 +39,6 @@ + diff --git a/Source/Mockolate.Migration.Analyzers/AnalyzerReleases.Shipped.md b/Source/Mockolate.Migration.Analyzers/AnalyzerReleases.Shipped.md index 2dbefb1..bb622dd 100644 --- a/Source/Mockolate.Migration.Analyzers/AnalyzerReleases.Shipped.md +++ b/Source/Mockolate.Migration.Analyzers/AnalyzerReleases.Shipped.md @@ -2,6 +2,7 @@ ### New Rules - Rule ID | Category | Severity | Notes ---------------|----------|----------|------------------------------------------- - MockolateM001 | Usage | Warning | Moq should be migrated. + Rule ID | Category | Severity | Notes +---------------|----------|----------|--------------------------------- + MockolateM001 | Usage | Warning | Moq should be migrated. + MockolateM002 | Usage | Warning | NSubstitute should be migrated. diff --git a/Source/Mockolate.Migration.Analyzers/NSubstituteAnalyzer.cs b/Source/Mockolate.Migration.Analyzers/NSubstituteAnalyzer.cs new file mode 100644 index 0000000..e174e26 --- /dev/null +++ b/Source/Mockolate.Migration.Analyzers/NSubstituteAnalyzer.cs @@ -0,0 +1,68 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Mockolate.Migration.Analyzers; + +/// +/// An analyzer that flags substitute creation from NSubstitute (Substitute.For<T>(), +/// Substitute.ForPartsOf<T>() and Substitute.ForTypeForwardingTo<TInterface, TClass>()). +/// +/// +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class NSubstituteAnalyzer : DiagnosticAnalyzer +{ + /// + public override ImmutableArray SupportedDiagnostics { get; } = [Rules.NSubstituteRule,]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterOperationAction(AnalyzeOperation, OperationKind.Invocation); + } + + private static void AnalyzeOperation(OperationAnalysisContext context) + { + if (context.Operation is not IInvocationOperation invocationOperation) + { + return; + } + + IMethodSymbol methodSymbol = invocationOperation.TargetMethod; + INamedTypeSymbol? containingType = methodSymbol.ContainingType; + + if (containingType is null || + containingType.Name != "Substitute" || + containingType.ContainingNamespace?.ToDisplayString() != "NSubstitute") + { + return; + } + + if (methodSymbol.Name is not ("For" or "ForPartsOf" or "ForTypeForwardingTo")) + { + return; + } + + SyntaxNode syntax = invocationOperation.Syntax; + while (syntax.Parent is ExpressionOrPatternSyntax && syntax.Parent is not AwaitExpressionSyntax) + { + syntax = syntax.Parent; + } + + if (syntax.Parent is ArgumentSyntax) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create(Rules.NSubstituteRule, syntax.GetLocation()) + ); + } +} diff --git a/Source/Mockolate.Migration.Analyzers/Resources.Designer.cs b/Source/Mockolate.Migration.Analyzers/Resources.Designer.cs index 158affe..7cf14ed 100644 --- a/Source/Mockolate.Migration.Analyzers/Resources.Designer.cs +++ b/Source/Mockolate.Migration.Analyzers/Resources.Designer.cs @@ -94,5 +94,41 @@ internal static string MockolateM001Title { return ResourceManager.GetString("MockolateM001Title", resourceCulture); } } + + /// + /// Looks up a localized string similar to Migrate NSubstitute to Mockolate. + /// + internal static string MockolateM002CodeFixTitle { + get { + return ResourceManager.GetString("MockolateM002CodeFixTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Migrate the mocks from NSubstitute to Mockolate.. + /// + internal static string MockolateM002Description { + get { + return ResourceManager.GetString("MockolateM002Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to NSubstitute should be migrated to Mockolate.. + /// + internal static string MockolateM002MessageFormat { + get { + return ResourceManager.GetString("MockolateM002MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to NSubstitute should be migrated.. + /// + internal static string MockolateM002Title { + get { + return ResourceManager.GetString("MockolateM002Title", resourceCulture); + } + } } } diff --git a/Source/Mockolate.Migration.Analyzers/Resources.resx b/Source/Mockolate.Migration.Analyzers/Resources.resx index be46d67..f66f5a8 100644 --- a/Source/Mockolate.Migration.Analyzers/Resources.resx +++ b/Source/Mockolate.Migration.Analyzers/Resources.resx @@ -37,4 +37,17 @@ Migrate Moq to Mockolate The title of the code fix. + + Migrate the mocks from NSubstitute to Mockolate. + + + NSubstitute should be migrated to Mockolate. + + + NSubstitute should be migrated. + + + Migrate NSubstitute to Mockolate + The title of the code fix. + diff --git a/Source/Mockolate.Migration.Analyzers/Rules.cs b/Source/Mockolate.Migration.Analyzers/Rules.cs index 3e4a391..d4e719b 100644 --- a/Source/Mockolate.Migration.Analyzers/Rules.cs +++ b/Source/Mockolate.Migration.Analyzers/Rules.cs @@ -3,18 +3,25 @@ namespace Mockolate.Migration.Analyzers; /// -/// The rules for the analyzers in this project. +/// The rules for the analyzers in this project. /// public static class Rules { private const string UsageCategory = "Usage"; /// - /// Migration rule for Moq usage. Flags any usage of `new Mock<T>()` or `new Mock<T>()` with target-typed new. + /// Migration rule for Moq usage. Flags any usage of `new Mock<T>()` or `new Mock<T>()` with target-typed new. /// public static readonly DiagnosticDescriptor MoqRule = CreateDescriptor("MockolateM001", UsageCategory, DiagnosticSeverity.Warning); + /// + /// Migration rule for NSubstitute usage. Flags any usage of `Substitute.For<T>()`, `Substitute.ForPartsOf<T>()`, + /// or `Substitute.ForTypesForwardingTo(...)`. + /// + public static readonly DiagnosticDescriptor NSubstituteRule = + CreateDescriptor("MockolateM002", UsageCategory, DiagnosticSeverity.Warning); + private static DiagnosticDescriptor CreateDescriptor(string diagnosticId, string category, DiagnosticSeverity severity) => new( diff --git a/Tests/Mockolate.Migration.Tests/NSubstituteAnalyzerTests.cs b/Tests/Mockolate.Migration.Tests/NSubstituteAnalyzerTests.cs new file mode 100644 index 0000000..83553df --- /dev/null +++ b/Tests/Mockolate.Migration.Tests/NSubstituteAnalyzerTests.cs @@ -0,0 +1,141 @@ +using Mockolate.Migration.Analyzers; +using Verifier = Mockolate.Migration.Tests.Verifiers.CSharpAnalyzerVerifier; + +namespace Mockolate.Migration.Tests; + +public class NSubstituteAnalyzerTests +{ + [Fact] + public async Task ArgumentMatcher_IsNotFlagged() + => await Verifier.VerifyAnalyzerAsync(""" + using NSubstitute; + + public interface IFoo { int Add(int a, int b); } + + public class Tests + { + public void Test() + { + var sub = {|#0:Substitute.For()|}; + sub.Add(Arg.Any(), Arg.Any()).Returns(42); + } + } + """, + Verifier.Diagnostic(Rules.NSubstituteRule) + .WithLocation(0)); + + [Fact] + public async Task SubstituteFor_IsFlagged() + => await Verifier.VerifyAnalyzerAsync(""" + using NSubstitute; + + public interface IFoo { } + + public class Tests + { + public void Test() + { + var sub = {|#0:Substitute.For()|}; + } + } + """, + Verifier.Diagnostic(Rules.NSubstituteRule) + .WithLocation(0)); + + [Fact] + public async Task SubstituteFor_NestedAsArgument_IsNotFlagged() + => await Verifier.VerifyAnalyzerAsync(""" + using NSubstitute; + + public interface IFoo { } + public interface IBar { IFoo GetFoo(); } + + public class Tests + { + public void Test() + { + var bar = {|#0:Substitute.For()|}; + bar.GetFoo().Returns(Substitute.For()); + } + } + """, + Verifier.Diagnostic(Rules.NSubstituteRule) + .WithLocation(0)); + + [Fact] + public async Task SubstituteFor_WithConstructorArguments_IsFlagged() + => await Verifier.VerifyAnalyzerAsync(""" + using NSubstitute; + + public class Foo + { + public Foo(string name, int count) { } + } + + public class Tests + { + public void Test() + { + var sub = {|#0:Substitute.For("name", 42)|}; + } + } + """, + Verifier.Diagnostic(Rules.NSubstituteRule) + .WithLocation(0)); + + [Fact] + public async Task SubstituteFor_WithMultipleInterfaces_IsFlagged() + => await Verifier.VerifyAnalyzerAsync(""" + using NSubstitute; + + public interface IFoo { } + public interface IBar { } + + public class Tests + { + public void Test() + { + var sub = {|#0:Substitute.For()|}; + } + } + """, + Verifier.Diagnostic(Rules.NSubstituteRule) + .WithLocation(0)); + + [Fact] + public async Task SubstituteForPartsOf_IsFlagged() + => await Verifier.VerifyAnalyzerAsync(""" + using NSubstitute; + + public class Foo { public virtual int Bar() => 0; } + + public class Tests + { + public void Test() + { + var sub = {|#0:Substitute.ForPartsOf()|}; + } + } + """, + Verifier.Diagnostic(Rules.NSubstituteRule) + .WithLocation(0)); + + [Fact] + public async Task SubstituteForTypeForwardingTo_IsFlagged() + => await Verifier.VerifyAnalyzerAsync(""" + using NSubstitute; + + public interface IFoo { void Bar(); } + public class FooImpl : IFoo { public void Bar() { } } + + public class Tests + { + public void Test() + { + var sub = {|#0:Substitute.ForTypeForwardingTo()|}; + } + } + """, + Verifier.Diagnostic(Rules.NSubstituteRule) + .WithLocation(0)); +} diff --git a/Tests/Mockolate.Migration.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs b/Tests/Mockolate.Migration.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs index 330d554..32b6b0f 100644 --- a/Tests/Mockolate.Migration.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs +++ b/Tests/Mockolate.Migration.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs @@ -32,6 +32,7 @@ public static async Task VerifyAnalyzerAsync([StringSyntax("c#-test")] string so ReferenceAssemblies = ReferenceAssemblies.Net.Net80.AddPackages( [ new PackageIdentity("Moq", "4.20.72"), + new PackageIdentity("NSubstitute", "5.3.0"), ]), TestState = { diff --git a/Tests/Mockolate.Migration.Tests/Verifiers/CSharpCodeFixVerifier`2.cs b/Tests/Mockolate.Migration.Tests/Verifiers/CSharpCodeFixVerifier`2.cs index 47cf42b..128bb29 100644 --- a/Tests/Mockolate.Migration.Tests/Verifiers/CSharpCodeFixVerifier`2.cs +++ b/Tests/Mockolate.Migration.Tests/Verifiers/CSharpCodeFixVerifier`2.cs @@ -37,6 +37,7 @@ params DiagnosticResult[] expected ReferenceAssemblies = ReferenceAssemblies.Net.Net80.AddPackages( [ new PackageIdentity("Moq", "4.20.72"), + new PackageIdentity("NSubstitute", "5.3.0"), ]), TestState = { @@ -78,6 +79,7 @@ public static async Task VerifyCodeFixAsync( ReferenceAssemblies = ReferenceAssemblies.Net.Net80.AddPackages( [ new PackageIdentity("Moq", "4.20.72"), + new PackageIdentity("NSubstitute", "5.3.0"), ]), TestState = { @@ -112,6 +114,7 @@ public static async Task VerifyLegacyCodeFixAsync( ReferenceAssemblies = ReferenceAssemblies.Net.Net80.AddPackages( [ new PackageIdentity("Moq", "4.20.72"), + new PackageIdentity("NSubstitute", "5.3.0"), ]), TestState = {