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 =
{