diff --git a/FastMoq.Analyzers.Tests/AnalyzerTestHelpers.cs b/FastMoq.Analyzers.Tests/AnalyzerTestHelpers.cs index 60a105a0..80a9826b 100644 --- a/FastMoq.Analyzers.Tests/AnalyzerTestHelpers.cs +++ b/FastMoq.Analyzers.Tests/AnalyzerTestHelpers.cs @@ -132,6 +132,11 @@ public static string NormalizeCode(string source) .ToFullString(); } + public static Document CreateDocumentForTest(string source, bool includeAzureFunctionsHelpers = false, bool includeMoqProviderPackage = true, bool includeNSubstituteProviderPackage = true, bool includeWebHelpers = true) + { + return CreateDocument(source, includeAzureFunctionsHelpers, includeMoqProviderPackage, includeNSubstituteProviderPackage, includeWebHelpers); + } + private static Document CreateDocument(string source, bool includeAzureFunctionsHelpers = false, bool includeMoqProviderPackage = true, bool includeNSubstituteProviderPackage = true, bool includeWebHelpers = true) { var project = CreateProject([("Test.cs", source)], includeAzureFunctionsHelpers, includeMoqProviderPackage, includeNSubstituteProviderPackage, includeWebHelpers); diff --git a/FastMoq.Analyzers.Tests/MigrationAnalyzerTests.cs b/FastMoq.Analyzers.Tests/MigrationAnalyzerTests.cs index 7a57a756..d2638d95 100644 --- a/FastMoq.Analyzers.Tests/MigrationAnalyzerTests.cs +++ b/FastMoq.Analyzers.Tests/MigrationAnalyzerTests.cs @@ -3,10 +3,12 @@ using FastMoq.Analyzers.CodeFixes; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; using System; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using Xunit; @@ -32,6 +34,8 @@ public class MigrationAnalyzerTests { new OptionsSetupAnalyzer(), DiagnosticDescriptors.PreferSetupOptionsHelper }, { new LoggerFactoryRegistrationAnalyzer(), DiagnosticDescriptors.PreferLoggerFactoryHelpers }, { new LoggerSetupCallbackAnalyzer(), DiagnosticDescriptors.PreferSetupLoggerCallbackHelper }, + { new DirectMockerTestBaseInheritanceAnalyzer(), DiagnosticDescriptors.DirectMockerTestBaseInheritance }, + { new UnnecessaryMockerTestBaseHelperIndirectionAnalyzer(), DiagnosticDescriptors.UnnecessaryMockerTestBaseHelperIndirection }, { new SetupSetAnalyzer(), DiagnosticDescriptors.PreferPropertySetterCaptureHelper }, { new SetupAllPropertiesAnalyzer(), DiagnosticDescriptors.PreferPropertyStateHelper }, { new TrackedMockVerificationAnalyzer(), DiagnosticDescriptors.UseProviderFirstVerify }, @@ -94,6 +98,8 @@ public class MigrationAnalyzerTests { DiagnosticDescriptors.PreserveKeyedServiceDistinctness, DiagnosticSeverity.Warning }, { DiagnosticDescriptors.PreserveTrackedResolutionDuringAddTypeMigration, DiagnosticSeverity.Warning }, { DiagnosticDescriptors.RequireExplicitMoqOnboarding, DiagnosticSeverity.Warning }, + { DiagnosticDescriptors.DirectMockerTestBaseInheritance, DiagnosticSeverity.Warning }, + { DiagnosticDescriptors.UnnecessaryMockerTestBaseHelperIndirection, DiagnosticSeverity.Info }, }; [Theory] @@ -110,6 +116,1138 @@ public void Descriptor_ShouldExposeExpectedDefaultSeverity(DiagnosticDescriptor Assert.Equal(expectedSeverity, descriptor.DefaultSeverity); } + [Fact] + public async Task DirectMockerTestBaseInheritanceCandidate_ShouldBeFixable_WhenHelperSetupIsEmpty() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : MockerTestBase + { + public SampleService C => Component; + } +}"; + + var candidate = await GetDirectMockerTestBaseInheritanceCandidateAsync(SOURCE, "SampleTests"); + + Assert.Equal("SampleTests", candidate.OuterType.Name); + Assert.Equal("TestHelper", candidate.HelperType.Name); + Assert.Equal("SampleService", candidate.TargetType.Name); + Assert.True(candidate.IsFixable); + Assert.Equal(MockerTestBaseHelperCompositionFixBlockReason.None, candidate.FixBlockReason); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCandidate_ShouldBlockFix_WhenHelperOwnsUnsupportedHooks() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests +{ + private readonly TestHelper _helper = new(); + + SampleService Component => _helper.C; + + private sealed class TestHelper : MockerTestBase + { + public SampleService C => Component; + + protected override Action CreatedComponentAction => component => + { + }; + } +}"; + + var candidate = await GetDirectMockerTestBaseInheritanceCandidateAsync(SOURCE, "SampleTests"); + + Assert.False(candidate.IsFixable); + Assert.Equal(MockerTestBaseHelperCompositionFixBlockReason.UnsupportedHooks, candidate.FixBlockReason); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCandidate_ShouldBlockFix_WhenHelperConstructorHasSetupStatements() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + private sealed class TestHelper : MockerTestBase + { + public TestHelper(Xunit.ITestOutputHelper output) + { + Mocks.AddLoggerFactory(output.WriteLine); + } + + public SampleService C => Component; + } +}"; + + var candidate = await GetDirectMockerTestBaseInheritanceCandidateAsync(SOURCE, "SampleTests"); + + Assert.False(candidate.IsFixable); + Assert.Equal(MockerTestBaseHelperCompositionFixBlockReason.UnsupportedForNow, candidate.FixBlockReason); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceAnalyzer_ShouldReport_WhenPrimaryConstructorOuterWrapsLocalHelper() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : MockerTestBase + { + public SampleService C => Component; + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync(SOURCE, new DirectMockerTestBaseInheritanceAnalyzer()); + var diagnostic = Assert.Single(diagnostics.Where(item => item.Id == DiagnosticIds.DirectMockerTestBaseInheritance)); + + Assert.Equal(DiagnosticIds.DirectMockerTestBaseInheritance, diagnostic.Id); + Assert.Contains("SampleTests", diagnostic.GetMessage()); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceAnalyzer_ShouldReport_WhenTraditionalConstructorOuterWrapsLocalHelper() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests +{ + private readonly TestHelper _helper; + + public SampleTests(Xunit.ITestOutputHelper output) + { + _helper = new TestHelper(output); + } + + SampleService Component => _helper.C; + + private sealed class TestHelper : MockerTestBase + { + public TestHelper(Xunit.ITestOutputHelper output) + { + } + + public SampleService C => Component; + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync(SOURCE, new DirectMockerTestBaseInheritanceAnalyzer()); + var diagnostic = Assert.Single(diagnostics.Where(item => item.Id == DiagnosticIds.DirectMockerTestBaseInheritance)); + + Assert.Equal(DiagnosticIds.DirectMockerTestBaseInheritance, diagnostic.Id); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceAnalyzer_ShouldNotReport_WhenOuterAlreadyUsesSharedInheritedBase() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +abstract class SharedBase : MockerTestBase +{ +} + +class SampleTests : SharedBase +{ + private readonly TestHelper _helper = new(); + + SampleService Component => _helper.C; + + private sealed class TestHelper : MockerTestBase + { + public SampleService C => Component; + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync(SOURCE, new DirectMockerTestBaseInheritanceAnalyzer()); + + Assert.DoesNotContain(diagnostics, item => item.Id == DiagnosticIds.DirectMockerTestBaseInheritance); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceAnalyzer_ShouldNotReport_WhenHelperOwnsMeaningfulBehavior() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : MockerTestBase + { + public SampleService C => Component; + + public void Arrange() + { + } + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync(SOURCE, new DirectMockerTestBaseInheritanceAnalyzer()); + + Assert.DoesNotContain(diagnostics, item => item.Id == DiagnosticIds.DirectMockerTestBaseInheritance); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldRewrite_PrimaryConstructorWrapper() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : MockerTestBase + { + public SampleService C => Component; + } +}"; + + var fixedSource = await AnalyzerTestHelpers.ApplyCodeFixAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance, + codeFixTitle: "Use direct MockerTestBase inheritance"); + + var expected = AnalyzerTestHelpers.NormalizeCode(@" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) : MockerTestBase +{ +}"); + + Assert.Equal(expected, fixedSource); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldRewrite_TraditionalConstructorWrapper() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests +{ + private readonly TestHelper _helper; + + public SampleTests(Xunit.ITestOutputHelper output) + { + _helper = new TestHelper(output); + } + + SampleService Component => _helper.C; + + private sealed class TestHelper : MockerTestBase + { + public TestHelper(Xunit.ITestOutputHelper output) + { + } + + public SampleService C => Component; + } +}"; + + var fixedSource = await AnalyzerTestHelpers.ApplyCodeFixAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance, + codeFixTitle: "Use direct MockerTestBase inheritance"); + + var expected = AnalyzerTestHelpers.NormalizeCode(@" +using FastMoq; + +class SampleService +{ +} + +class SampleTests : MockerTestBase +{ + public SampleTests(Xunit.ITestOutputHelper output) + { + } +}"); + + Assert.Equal(expected, fixedSource); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldPreserveImplementedInterfaces() + { + const string SOURCE = @" +using System; +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) : IDisposable +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + public void Dispose() + { + } + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : MockerTestBase + { + public SampleService C => Component; + } +}"; + + var fixedSource = await AnalyzerTestHelpers.ApplyCodeFixAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance, + codeFixTitle: "Use direct MockerTestBase inheritance"); + + var expected = AnalyzerTestHelpers.NormalizeCode(@" +using System; +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) : MockerTestBase, IDisposable +{ + public void Dispose() + { + } +}"); + + Assert.Equal(expected, fixedSource); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldRewrite_ThisQualifiedHelperAccess() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests +{ + private readonly TestHelper _helper; + + public SampleTests(Xunit.ITestOutputHelper output) + { + this._helper = new TestHelper(output); + } + + SampleService Component => this._helper.C; + + private sealed class TestHelper : MockerTestBase + { + public TestHelper(Xunit.ITestOutputHelper output) + { + } + + public SampleService C => Component; + } +}"; + + var fixedSource = await AnalyzerTestHelpers.ApplyCodeFixAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance, + codeFixTitle: "Use direct MockerTestBase inheritance"); + + var expected = AnalyzerTestHelpers.NormalizeCode(@" +using FastMoq; + +class SampleService +{ +} + +class SampleTests : MockerTestBase +{ + public SampleTests(Xunit.ITestOutputHelper output) + { + } +}"); + + Assert.Equal(expected, fixedSource); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldAddFastMoqUsing_WhenOnlyFullyQualifiedHelperBaseExists() + { + const string SOURCE = @" +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : FastMoq.MockerTestBase + { + public SampleService C => Component; + } +}"; + + var fixedSource = await AnalyzerTestHelpers.ApplyCodeFixAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance, + codeFixTitle: "Use direct MockerTestBase inheritance"); + + var expected = AnalyzerTestHelpers.NormalizeCode(@" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) : MockerTestBase +{ +}"); + + Assert.Equal(expected, fixedSource); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldAddFastMoqUsing_WhenAnotherNamespaceOwnsTheOnlyNamespaceScopedUsing() + { + const string SOURCE = @" +namespace ExistingNamespace +{ + using FastMoq; + + class ExistingTests : MockerTestBase + { + } +} + +namespace TargetNamespace +{ + class SampleService + { + } + + class SampleTests(Xunit.ITestOutputHelper output) + { + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : FastMoq.MockerTestBase + { + public SampleService C => Component; + } + } +}"; + + var fixedSource = await AnalyzerTestHelpers.ApplyCodeFixAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance, + codeFixTitle: "Use direct MockerTestBase inheritance"); + + var expected = AnalyzerTestHelpers.NormalizeCode(@" +using FastMoq; + +namespace ExistingNamespace +{ + using FastMoq; + + class ExistingTests : MockerTestBase + { + } +} + +namespace TargetNamespace +{ + class SampleService + { + } + + class SampleTests(Xunit.ITestOutputHelper output) : MockerTestBase + { + } +}"); + + Assert.Equal(expected, fixedSource); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldPreserveNonShadowingOuterAlias() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Subject => _helper.C; + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : MockerTestBase + { + public SampleService C => Component; + } +}"; + + var fixedSource = await AnalyzerTestHelpers.ApplyCodeFixAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance, + codeFixTitle: "Use direct MockerTestBase inheritance"); + + var expected = AnalyzerTestHelpers.NormalizeCode(@" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) : MockerTestBase +{ + SampleService Subject => base.Component; +}"); + + Assert.Equal(expected, fixedSource); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldRemoveRedundantMocksAlias() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests +{ + private readonly TestHelper _helper = new(); + + Mocker Mocks => _helper.Store; + + private sealed class TestHelper : MockerTestBase + { + public Mocker Store => Mocks; + } +}"; + + var fixedSource = await AnalyzerTestHelpers.ApplyCodeFixAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance, + codeFixTitle: "Use direct MockerTestBase inheritance"); + + var expected = AnalyzerTestHelpers.NormalizeCode(@" +using FastMoq; + +class SampleService +{ +} + +class SampleTests : MockerTestBase +{ +}"); + + Assert.Equal(expected, fixedSource); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldNotBeOffered_WhenHelperOwnsUnsupportedHooks() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests +{ + private readonly TestHelper _helper = new(); + + SampleService Component => _helper.C; + + private sealed class TestHelper : MockerTestBase + { + public SampleService C => Component; + + protected override Action CreatedComponentAction => component => + { + }; + } +}"; + + var codeFixTitles = await AnalyzerTestHelpers.GetCodeFixTitlesAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance); + + Assert.Empty(codeFixTitles); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldNotBeOffered_WhenHelperConstructorContainsSetupStatements() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + private sealed class TestHelper : MockerTestBase + { + public TestHelper(Xunit.ITestOutputHelper output) + { + Mocks.AddLoggerFactory(output.WriteLine); + } + + public SampleService C => Component; + } +}"; + + var codeFixTitles = await AnalyzerTestHelpers.GetCodeFixTitlesAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance); + + Assert.Empty(codeFixTitles); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldNotBeOffered_WhenHelperAssignmentUsesObjectInitializer() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests +{ + private readonly TestHelper _helper; + + public SampleTests(Xunit.ITestOutputHelper output) + { + _helper = new TestHelper(output) + { + Strict = true, + }; + } + + SampleService Component => _helper.C; + + private sealed class TestHelper : MockerTestBase + { + public TestHelper(Xunit.ITestOutputHelper output) + { + } + + public SampleService C => Component; + } +}"; + + var codeFixTitles = await AnalyzerTestHelpers.GetCodeFixTitlesAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance); + + Assert.Empty(codeFixTitles); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldNotBeOffered_WhenHelperAssignmentUsesExistingHelperValue() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests +{ + private readonly TestHelper _helper; + + public SampleTests(Xunit.ITestOutputHelper output) + { + var existingHelper = new TestHelper(output); + _helper = existingHelper; + } + + SampleService Component => _helper.C; + + private sealed class TestHelper : MockerTestBase + { + public TestHelper(Xunit.ITestOutputHelper output) + { + } + + public SampleService C => Component; + } +}"; + + var codeFixTitles = await AnalyzerTestHelpers.GetCodeFixTitlesAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance); + + Assert.Empty(codeFixTitles); + } + + [Fact] + public async Task DirectMockerTestBaseInheritanceCodeFix_ShouldNotBeOffered_WhenHelperFieldHasUnsupportedReference() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + TestHelper Helper => _helper; + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : MockerTestBase + { + public SampleService C => Component; + } +}"; + + var codeFixTitles = await AnalyzerTestHelpers.GetCodeFixTitlesAsync( + SOURCE, + new DirectMockerTestBaseInheritanceAnalyzer(), + codeFixProvider, + DiagnosticIds.DirectMockerTestBaseInheritance); + + Assert.Empty(codeFixTitles); + } + + [Fact] + public async Task UnnecessaryMockerTestBaseHelperIndirectionAnalyzer_ShouldReport_WhenComponentAliasOnlyForwardsToHelper() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : MockerTestBase + { + public SampleService C => Component; + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync(SOURCE, new UnnecessaryMockerTestBaseHelperIndirectionAnalyzer()); + var diagnostic = Assert.Single(diagnostics.Where(item => item.Id == DiagnosticIds.UnnecessaryMockerTestBaseHelperIndirection)); + + Assert.Equal(DiagnosticIds.UnnecessaryMockerTestBaseHelperIndirection, diagnostic.Id); + } + + [Fact] + public async Task UnnecessaryMockerTestBaseHelperIndirectionAnalyzer_ShouldNotReport_WhenPropertyUsesDifferentVocabulary() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Subject => _helper.C; + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : MockerTestBase + { + public SampleService C => Component; + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync(SOURCE, new UnnecessaryMockerTestBaseHelperIndirectionAnalyzer()); + + Assert.DoesNotContain(diagnostics, item => item.Id == DiagnosticIds.UnnecessaryMockerTestBaseHelperIndirection); + } + + [Fact] + public async Task UnnecessaryMockerTestBaseHelperIndirectionAnalyzer_ShouldReport_WhenMocksAliasOnlyForwardsToHelper() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests +{ + private readonly TestHelper _helper = new(); + + Mocker Mocks => _helper.Store; + + private sealed class TestHelper : MockerTestBase + { + public Mocker Store => Mocks; + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync(SOURCE, new UnnecessaryMockerTestBaseHelperIndirectionAnalyzer()); + var diagnostic = Assert.Single(diagnostics.Where(item => item.Id == DiagnosticIds.UnnecessaryMockerTestBaseHelperIndirection)); + + Assert.Equal(DiagnosticIds.UnnecessaryMockerTestBaseHelperIndirection, diagnostic.Id); + Assert.Contains("Store", diagnostic.GetMessage()); + } + + [Fact] + public async Task UnnecessaryMockerTestBaseHelperIndirectionAnalyzer_ShouldReport_WhenComponentAliasUsesParentheses() + { + const string SOURCE = @" +using FastMoq; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => (_helper.C); + + private sealed class TestHelper(Xunit.ITestOutputHelper output) : MockerTestBase + { + public SampleService C => Component; + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync(SOURCE, new UnnecessaryMockerTestBaseHelperIndirectionAnalyzer()); + var diagnostic = Assert.Single(diagnostics.Where(item => item.Id == DiagnosticIds.UnnecessaryMockerTestBaseHelperIndirection)); + + Assert.Equal(DiagnosticIds.UnnecessaryMockerTestBaseHelperIndirection, diagnostic.Id); + } + + [Fact] + public async Task TrackedMockShimAnalyzer_ShouldReport_WhenHelperCompositionExposesVerificationOnlyMockAlias() + { + const string SOURCE = @" +using FastMoq; +using FastMoq.Providers.MoqProvider; +using Moq; + +class SampleService +{ +} + +interface IService +{ + void Run(); +} + +class SampleTests +{ + private readonly TestHelper _helper = new(); + + void Execute() + { + _helper.VerifyDependency(); + } + + private sealed class TestHelper : MockerTestBase + { + public Mock DependencyMock => Mocks.GetMock(); + + public void VerifyDependency() + { + DependencyMock.Verify(x => x.Run()); + } + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync( + SOURCE, + includeAzureFunctionsHelpers: false, + includeMoqProviderPackage: true, + includeNSubstituteProviderPackage: true, + new TrackedMockShimAnalyzer()); + var diagnostic = Assert.Single(diagnostics.Where(item => item.Id == DiagnosticIds.AvoidTrackedMockShimAlias)); + + Assert.Equal(DiagnosticIds.AvoidTrackedMockShimAlias, diagnostic.Id); + } + + [Fact] + public async Task LoggerFactoryRegistrationAnalyzer_ShouldReport_WhenNestedHelperRegistersOutputLoggerFactory() + { + const string SOURCE = @" +using FastMoq; +using Microsoft.Extensions.Logging; + +class SampleService +{ +} + +class SampleTests(Xunit.ITestOutputHelper output) +{ + private readonly TestHelper _helper = new(output); + + SampleService Component => _helper.C; + + private sealed class TestHelper : MockerTestBase + { + public TestHelper(Xunit.ITestOutputHelper output) + { + Mocks.AddType(new OutputLoggerFactory(output), true); + } + + public SampleService C => Component; + } +} + +sealed class OutputLoggerFactory : ILoggerFactory +{ + public OutputLoggerFactory(Xunit.ITestOutputHelper output) + { + } + + public void AddProvider(ILoggerProvider provider) + { + } + + public ILogger CreateLogger(string categoryName) => throw new System.NotImplementedException(); + + public void Dispose() + { + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync(SOURCE, new LoggerFactoryRegistrationAnalyzer()); + var diagnostic = Assert.Single(diagnostics.Where(item => item.Id == DiagnosticIds.PreferLoggerFactoryHelpers)); + + Assert.Equal(DiagnosticIds.PreferLoggerFactoryHelpers, diagnostic.Id); + } + + [Fact] + public async Task FastMockVerifyHelperAnalyzer_ShouldReport_WhenNestedHelperWrapsDetachedProviderVerify() + { + const string SOURCE = @" +using System; +using System.Linq.Expressions; +using FastMoq; +using FastMoq.Providers; + +class SampleService +{ +} + +interface IService +{ + void Run(); +} + +class SampleTests +{ + private readonly TestHelper _helper = new(); + + private sealed class TestHelper : MockerTestBase + { + internal void Verify(IFastMock fastMock, Expression> expression, TimesSpec? times = null) + where T : class + => MockingProviderRegistry.Default.Verify(fastMock, expression, times); + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync(SOURCE, new FastMockVerifyHelperAnalyzer()); + var diagnostic = Assert.Single(diagnostics.Where(item => item.Id == DiagnosticIds.AvoidFastMockVerifyHelperWrappers)); + + Assert.Equal(DiagnosticIds.AvoidFastMockVerifyHelperWrappers, diagnostic.Id); + } + + [Fact] + public async Task FastMockVerifyHelperAnalyzer_ShouldReport_WhenNestedHelperWrapsProviderSpecificVerify() + { + const string SOURCE = @" +using System; +using System.Linq.Expressions; +using FastMoq; +using FastMoq.Providers; +using FastMoq.Providers.MoqProvider; +using Moq; + +class SampleService +{ +} + +interface IService +{ + int Run(); +} + +class SampleTests +{ + private readonly TestHelper _helper = new(); + + private sealed class TestHelper : MockerTestBase + { + internal void Verify(IFastMock fastMock, Expression> expression, TimesSpec times) + where T : class + => fastMock.AsMoq().Verify(expression, times.ToMoq()); + + private static Times ToMoq(TimesSpec times) + => times.Mode switch + { + TimesSpecMode.AtLeastOnce => Times.AtLeastOnce(), + TimesSpecMode.Exactly => Times.Exactly(times.Count ?? 0), + TimesSpecMode.AtLeast => Times.AtLeast(times.Count ?? 0), + TimesSpecMode.AtMost => Times.AtMost(times.Count ?? 0), + TimesSpecMode.Never => Times.Never(), + _ => throw new ArgumentOutOfRangeException(nameof(times), times.Mode, null), + }; + } +}"; + + var diagnostics = await AnalyzerTestHelpers.GetDiagnosticsAsync( + SOURCE, + includeAzureFunctionsHelpers: false, + includeMoqProviderPackage: true, + includeNSubstituteProviderPackage: true, + new FastMockVerifyHelperAnalyzer()); + var diagnostic = Assert.Single(diagnostics.Where(item => item.Id == DiagnosticIds.AvoidProviderSpecificFastMockVerifyHelperWrappers)); + + Assert.Equal(DiagnosticIds.AvoidProviderSpecificFastMockVerifyHelperWrappers, diagnostic.Id); + Assert.DoesNotContain(diagnostics, item => item.Id == DiagnosticIds.AvoidFastMockVerifyHelperWrappers); + } + + private static async Task GetDirectMockerTestBaseInheritanceCandidateAsync(string source, string outerTypeName) + { + var document = AnalyzerTestHelpers.CreateDocumentForTest(source); + var root = await document.GetSyntaxRootAsync(CancellationToken.None).ConfigureAwait(false); + var semanticModel = await document.GetSemanticModelAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.NotNull(root); + Assert.NotNull(semanticModel); + + var outerTypeDeclaration = root! + .DescendantNodes() + .OfType() + .Single(typeDeclaration => typeDeclaration.Identifier.ValueText == outerTypeName); + + Assert.True( + FastMoqAnalysisHelpers.TryGetDirectMockerTestBaseInheritanceCandidate(outerTypeDeclaration, semanticModel!, CancellationToken.None, out var candidate)); + + return candidate; + } + [Fact] public void UseProviderFirstObjectAccess_ShouldExplainInstanceVersusObjectRetrieval() { diff --git a/FastMoq.Analyzers/Analyzers/DirectMockerTestBaseInheritanceAnalyzer.cs b/FastMoq.Analyzers/Analyzers/DirectMockerTestBaseInheritanceAnalyzer.cs new file mode 100644 index 00000000..bc48472f --- /dev/null +++ b/FastMoq.Analyzers/Analyzers/DirectMockerTestBaseInheritanceAnalyzer.cs @@ -0,0 +1,36 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; + +namespace FastMoq.Analyzers.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class DirectMockerTestBaseInheritanceAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.DirectMockerTestBaseInheritance); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeClassDeclaration, Microsoft.CodeAnalysis.CSharp.SyntaxKind.ClassDeclaration); + } + + private static void AnalyzeClassDeclaration(SyntaxNodeAnalysisContext context) + { + var classDeclaration = (ClassDeclarationSyntax) context.Node; + if (!FastMoqAnalysisHelpers.TryGetDirectMockerTestBaseInheritanceCandidate(classDeclaration, context.SemanticModel, context.CancellationToken, out var candidate)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.DirectMockerTestBaseInheritance, + classDeclaration.Identifier.GetLocation(), + candidate.OuterType.Name, + candidate.TargetType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), + candidate.HelperType.Name)); + } + } +} \ No newline at end of file diff --git a/FastMoq.Analyzers/Analyzers/UnnecessaryMockerTestBaseHelperIndirectionAnalyzer.cs b/FastMoq.Analyzers/Analyzers/UnnecessaryMockerTestBaseHelperIndirectionAnalyzer.cs new file mode 100644 index 00000000..b07b1879 --- /dev/null +++ b/FastMoq.Analyzers/Analyzers/UnnecessaryMockerTestBaseHelperIndirectionAnalyzer.cs @@ -0,0 +1,62 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Threading; + +namespace FastMoq.Analyzers.Analyzers +{ + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class UnnecessaryMockerTestBaseHelperIndirectionAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.UnnecessaryMockerTestBaseHelperIndirection); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzePropertyDeclaration, Microsoft.CodeAnalysis.CSharp.SyntaxKind.PropertyDeclaration); + } + + private static void AnalyzePropertyDeclaration(SyntaxNodeAnalysisContext context) + { + var propertyDeclaration = (PropertyDeclarationSyntax) context.Node; + if (context.SemanticModel.GetDeclaredSymbol(propertyDeclaration, context.CancellationToken) is not IPropertySymbol propertySymbol || + propertySymbol.IsStatic || + propertySymbol.Name is not "Component" and not "Mocks" || + propertyDeclaration.Parent is not TypeDeclarationSyntax containingType || + !FastMoqAnalysisHelpers.TryGetDirectMockerTestBaseInheritanceCandidate(containingType, context.SemanticModel, context.CancellationToken, out var candidate) || + !TryGetHelperForwardingMemberName(propertyDeclaration, context.SemanticModel, context.CancellationToken, candidate.HelperMember, out var helperMemberName)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.UnnecessaryMockerTestBaseHelperIndirection, + propertyDeclaration.Identifier.GetLocation(), + propertySymbol.Name, + helperMemberName)); + } + + private static bool TryGetHelperForwardingMemberName(PropertyDeclarationSyntax propertyDeclaration, SemanticModel semanticModel, CancellationToken cancellationToken, ISymbol helperMember, out string helperMemberName) + { + if (!FastMoqAnalysisHelpers.TryGetPropertyReturnExpression(propertyDeclaration, out var expression)) + { + helperMemberName = string.Empty; + return false; + } + + expression = FastMoqAnalysisHelpers.Unwrap(expression); + if (expression is not MemberAccessExpressionSyntax memberAccess || + semanticModel.GetSymbolInfo(memberAccess.Expression, cancellationToken).Symbol is not { } accessSymbol || + !SymbolEqualityComparer.Default.Equals(accessSymbol, helperMember)) + { + helperMemberName = string.Empty; + return false; + } + + helperMemberName = memberAccess.Name.Identifier.ValueText; + return true; + } + } +} \ No newline at end of file diff --git a/FastMoq.Analyzers/CodeFixes/FastMoqMigrationCodeFixProvider.cs b/FastMoq.Analyzers/CodeFixes/FastMoqMigrationCodeFixProvider.cs index b2ad8acd..550ac01c 100644 --- a/FastMoq.Analyzers/CodeFixes/FastMoqMigrationCodeFixProvider.cs +++ b/FastMoq.Analyzers/CodeFixes/FastMoqMigrationCodeFixProvider.cs @@ -26,6 +26,7 @@ public sealed class FastMoqMigrationCodeFixProvider : CodeFixProvider DiagnosticIds.UseProviderFirstReset, DiagnosticIds.UseVerifyLogged, DiagnosticIds.PreferSetupLoggerCallbackHelper, + DiagnosticIds.DirectMockerTestBaseInheritance, DiagnosticIds.UseProviderFirstVerify, DiagnosticIds.AvoidFastMockVerifyHelperWrappers, DiagnosticIds.AvoidProviderSpecificFastMockVerifyHelperWrappers, @@ -128,6 +129,30 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) break; } + case DiagnosticIds.DirectMockerTestBaseInheritance: + { + var classDeclaration = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + if (classDeclaration is null) + { + return; + } + + var semanticModel = await document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null || + !TryBuildDirectMockerTestBaseInheritanceFix(classDeclaration, semanticModel, context.CancellationToken, out _)) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + "Use direct MockerTestBase inheritance", + cancellationToken => ReplaceDirectMockerTestBaseInheritanceAsync(document, classDeclaration, cancellationToken), + nameof(DiagnosticIds.DirectMockerTestBaseInheritance)), + diagnostic); + break; + } + case DiagnosticIds.UseProviderFirstVerify: { var invocationExpression = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); @@ -578,7 +603,7 @@ invocationExpression.Expression is not MemberAccessExpressionSyntax memberAccess if (requiresProvidersNamespace) { - updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, FastMoqAnalysisHelpers.FastMoqProvidersNamespace); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, FastMoqAnalysisHelpers.FastMoqProvidersNamespace); } return document.WithSyntaxRoot(updatedRoot); @@ -600,12 +625,144 @@ private static async Task ReplaceFastMockVerifyWrapperInvocationAsync( if (requiresProvidersNamespace) { - updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, FastMoqAnalysisHelpers.FastMoqProvidersNamespace); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, FastMoqAnalysisHelpers.FastMoqProvidersNamespace); } return document.WithSyntaxRoot(updatedRoot); } + private static async Task ReplaceDirectMockerTestBaseInheritanceAsync(Document document, ClassDeclarationSyntax classDeclaration, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (root is null || semanticModel is null || + !TryBuildDirectMockerTestBaseInheritanceFix(classDeclaration, semanticModel, cancellationToken, out var fix)) + { + return document; + } + + var outerAnnotation = new SyntaxAnnotation(); + var helperDeclarationAnnotation = new SyntaxAnnotation(); + var helperFieldAnnotation = new SyntaxAnnotation(); + var memberAccessAnnotations = fix.MemberAccessReplacements + .Select(item => (Replacement: item, Annotation: new SyntaxAnnotation())) + .ToArray(); + var memberRemovalAnnotations = fix.MembersToRemove + .Select(item => (Member: item, Annotation: new SyntaxAnnotation())) + .ToArray(); + var statementAnnotations = fix.StatementsToRemove + .Select(item => (Statement: item, Annotation: new SyntaxAnnotation())) + .ToArray(); + var nodesToAnnotate = new List + { + fix.OuterClassDeclaration, + fix.HelperTypeDeclaration, + fix.HelperFieldDeclaration, + }; + nodesToAnnotate.AddRange(memberAccessAnnotations.Select(item => (SyntaxNode) item.Replacement.MemberAccess)); + nodesToAnnotate.AddRange(memberRemovalAnnotations.Select(item => (SyntaxNode) item.Member)); + nodesToAnnotate.AddRange(statementAnnotations.Select(item => (SyntaxNode) item.Statement)); + + var updatedRoot = root.ReplaceNodes( + nodesToAnnotate, + (originalNode, rewrittenNode) => + { + if (originalNode == fix.OuterClassDeclaration) + { + return rewrittenNode.WithAdditionalAnnotations(outerAnnotation); + } + + if (originalNode == fix.HelperTypeDeclaration) + { + return rewrittenNode.WithAdditionalAnnotations(helperDeclarationAnnotation); + } + + if (originalNode == fix.HelperFieldDeclaration) + { + return rewrittenNode.WithAdditionalAnnotations(helperFieldAnnotation); + } + + var memberAccessAnnotation = memberAccessAnnotations.SingleOrDefault(item => item.Replacement.MemberAccess == originalNode).Annotation; + if (memberAccessAnnotation is not null) + { + return rewrittenNode.WithAdditionalAnnotations(memberAccessAnnotation); + } + + var memberRemovalAnnotation = memberRemovalAnnotations.SingleOrDefault(item => item.Member == originalNode).Annotation; + if (memberRemovalAnnotation is not null) + { + return rewrittenNode.WithAdditionalAnnotations(memberRemovalAnnotation); + } + + var statementAnnotation = statementAnnotations.Single(item => item.Statement == originalNode).Annotation; + return rewrittenNode.WithAdditionalAnnotations(statementAnnotation); + }); + + foreach (var (replacement, annotation) in memberAccessAnnotations) + { + var currentMemberAccess = updatedRoot.GetAnnotatedNodes(annotation).OfType().SingleOrDefault(); + if (currentMemberAccess is null) + { + continue; + } + + updatedRoot = updatedRoot.ReplaceNode( + currentMemberAccess, + SyntaxFactory.ParseExpression(replacement.ReplacementText).WithTriviaFrom(currentMemberAccess)); + } + + foreach (var (_, annotation) in statementAnnotations) + { + var currentStatement = updatedRoot.GetAnnotatedNodes(annotation).OfType().SingleOrDefault(); + if (currentStatement is not null) + { + updatedRoot = updatedRoot.RemoveNode(currentStatement, SyntaxRemoveOptions.KeepExteriorTrivia) ?? updatedRoot; + } + } + + foreach (var (_, annotation) in memberRemovalAnnotations) + { + var currentMember = updatedRoot.GetAnnotatedNodes(annotation).OfType().SingleOrDefault(); + if (currentMember is not null) + { + updatedRoot = updatedRoot.RemoveNode(currentMember, SyntaxRemoveOptions.KeepExteriorTrivia) ?? updatedRoot; + } + } + + var helperDeclarationNode = updatedRoot.GetAnnotatedNodes(helperDeclarationAnnotation).SingleOrDefault(); + if (helperDeclarationNode is not null) + { + updatedRoot = updatedRoot.RemoveNode(helperDeclarationNode, SyntaxRemoveOptions.KeepExteriorTrivia) ?? updatedRoot; + } + + var helperFieldNode = updatedRoot.GetAnnotatedNodes(helperFieldAnnotation).SingleOrDefault(); + if (helperFieldNode is not null) + { + updatedRoot = updatedRoot.RemoveNode(helperFieldNode, SyntaxRemoveOptions.KeepExteriorTrivia) ?? updatedRoot; + } + + var annotatedOuter = (ClassDeclarationSyntax) updatedRoot.GetAnnotatedNodes(outerAnnotation).Single(); + var baseTypes = new SeparatedSyntaxList() + .Add( + SyntaxFactory.SimpleBaseType( + SyntaxFactory.ParseTypeName($"MockerTestBase<{fix.TargetTypeName}>"))); + + if (annotatedOuter.BaseList is not null) + { + foreach (var existingBaseType in annotatedOuter.BaseList.Types) + { + baseTypes = baseTypes.Add(existingBaseType); + } + } + + var replacementOuter = annotatedOuter.WithBaseList( + SyntaxFactory.BaseList(baseTypes)); + updatedRoot = updatedRoot.ReplaceNode(annotatedOuter, replacementOuter); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq"); + + return document.WithSyntaxRoot(updatedRoot); + } + private static async Task ReplaceFastArgMatcherAsync(Document document, InvocationExpressionSyntax invocationExpression, CancellationToken cancellationToken) { var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); @@ -619,7 +776,7 @@ private static async Task ReplaceFastArgMatcherAsync(Document document var replacementExpression = SyntaxFactory.ParseExpression(replacementText) .WithTriviaFrom(invocationExpression); var updatedRoot = root.ReplaceNode(invocationExpression, replacementExpression); - updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, FastMoqAnalysisHelpers.FastMoqProvidersNamespace); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, FastMoqAnalysisHelpers.FastMoqProvidersNamespace); return document.WithSyntaxRoot(updatedRoot); } @@ -680,7 +837,7 @@ private static async Task ReplaceWebHelperInvocationAsync(Document doc var replacementExpression = SyntaxFactory.ParseExpression(replacementText) .WithTriviaFrom(invocationExpression); var updatedRoot = root.ReplaceNode(invocationExpression, replacementExpression); - updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, "FastMoq.Web.Extensions"); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq.Web.Extensions"); return document.WithSyntaxRoot(updatedRoot); } @@ -732,7 +889,7 @@ private static async Task ReplaceWebHelperRequestBodyAssignmentAsync(D var replacementStatement = SyntaxFactory.ParseStatement(replacementText) .WithTriviaFrom(annotatedTargetStatement); updatedRoot = updatedRoot.ReplaceNode(annotatedTargetStatement, replacementStatement); - updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, "FastMoq.Web.Extensions"); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq.Web.Extensions"); return document.WithSyntaxRoot(updatedRoot); } @@ -753,7 +910,7 @@ private static async Task ReplaceSetupSetInvocationAsync(Document docu var replacementExpression = SyntaxFactory.ParseExpression(replacementText) .WithTriviaFrom(invocationExpression); var updatedRoot = root.ReplaceNode(invocationExpression, replacementExpression); - updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, "FastMoq.Extensions"); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq.Extensions"); return document.WithSyntaxRoot(updatedRoot); } @@ -774,7 +931,7 @@ private static async Task ReplaceSetupAllPropertiesInvocationAsync(Doc var replacementExpression = SyntaxFactory.ParseExpression(replacementText) .WithTriviaFrom(invocationExpression); var updatedRoot = root.ReplaceNode(invocationExpression, replacementExpression); - updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, "FastMoq.Extensions"); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq.Extensions"); return document.WithSyntaxRoot(updatedRoot); } @@ -886,7 +1043,7 @@ private static async Task ReplaceSetupOptionsInvocationAsync(Document .WithTriviaFrom(invocationExpression); var updatedRoot = root.ReplaceNode(invocationExpression, replacementExpression); - return document.WithSyntaxRoot(AddUsingDirectiveIfMissing(updatedRoot, "FastMoq.Extensions")); + return document.WithSyntaxRoot(AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq.Extensions")); } private static async Task ReplaceLoggerFactoryHelperInvocationAsync(Document document, InvocationExpressionSyntax invocationExpression, CancellationToken cancellationToken) @@ -908,7 +1065,7 @@ private static async Task ReplaceLoggerFactoryHelperInvocationAsync(Do .WithTriviaFrom(invocationExpression); var updatedRoot = root.ReplaceNode(invocationExpression, replacementExpression); - return document.WithSyntaxRoot(AddUsingDirectiveIfMissing(updatedRoot, "FastMoq.Extensions")); + return document.WithSyntaxRoot(AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq.Extensions")); } private static async Task ReplaceSetupLoggerCallbackInvocationAsync(Document document, InvocationExpressionSyntax invocationExpression, CancellationToken cancellationToken) @@ -930,7 +1087,7 @@ private static async Task ReplaceSetupLoggerCallbackInvocationAsync(Do .WithTriviaFrom(invocationExpression); var updatedRoot = root.ReplaceNode(invocationExpression, replacementExpression); - return document.WithSyntaxRoot(AddUsingDirectiveIfMissing(updatedRoot, "FastMoq.Extensions")); + return document.WithSyntaxRoot(AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq.Extensions")); } private static async Task ReplaceProviderNeutralHttpHelperInvocationAsync(Document document, InvocationExpressionSyntax invocationExpression, CancellationToken cancellationToken) @@ -1007,8 +1164,8 @@ private static async Task ReplaceProviderNeutralHttpHelperInvocationAs updatedRoot = updatedRoot.ReplaceNode(annotatedClientExpression, replacementExpression); } - updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, "FastMoq.Extensions"); - updatedRoot = AddUsingDirectivesIfMissing(updatedRoot, edit.RequiredNamespaces); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq.Extensions"); + updatedRoot = AddUsingDirectivesIfMissing(updatedRoot, semanticModel.Compilation, edit.RequiredNamespaces); return document.WithSyntaxRoot(updatedRoot); } @@ -1029,7 +1186,7 @@ private static async Task ReplaceFunctionContextInstanceServicesInvoca var replacementExpression = SyntaxFactory.ParseExpression(replacementText) .WithTriviaFrom(targetInvocation); var updatedRoot = root.ReplaceNode(targetInvocation, replacementExpression); - updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, "FastMoq.AzureFunctions.Extensions"); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq.AzureFunctions.Extensions"); return document.WithSyntaxRoot(updatedRoot); } @@ -1050,7 +1207,7 @@ private static async Task ReplaceFunctionContextInvocationIdInvocation var replacementExpression = SyntaxFactory.ParseExpression(replacementText) .WithTriviaFrom(targetInvocation); var updatedRoot = root.ReplaceNode(targetInvocation, replacementExpression); - updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, "FastMoq.AzureFunctions.Extensions"); + updatedRoot = AddUsingDirectiveIfMissing(updatedRoot, semanticModel.Compilation, "FastMoq.AzureFunctions.Extensions"); return document.WithSyntaxRoot(updatedRoot); } @@ -1101,7 +1258,7 @@ private static async Task ReplaceTypedServiceProviderHelperInvocationA var replacementExpression = SyntaxFactory.ParseExpression(replacementText) .WithTriviaFrom(annotatedTargetInvocation); updatedRoot = updatedRoot.ReplaceNode(annotatedTargetInvocation, replacementExpression); - updatedRoot = AddUsingDirectivesIfMissing(updatedRoot, requiredNamespaces); + updatedRoot = AddUsingDirectivesIfMissing(updatedRoot, semanticModel.Compilation, requiredNamespaces); return document.WithSyntaxRoot(updatedRoot); } @@ -1124,12 +1281,13 @@ private static async Task ReplaceGetMockAsync(Document document, Membe private static async Task AddAssemblyDefaultProviderAsync(Document document, string providerName, CancellationToken cancellationToken) { var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false) as CompilationUnitSyntax; + var compilation = await document.Project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); if (root is null || HasAssemblyDefaultProviderAttribute(root, providerName)) { return document; } - var updatedRoot = (CompilationUnitSyntax) AddUsingDirectiveIfMissing(root, FastMoqAnalysisHelpers.FastMoqProvidersNamespace); + var updatedRoot = (CompilationUnitSyntax) AddUsingDirectiveIfMissing(root, compilation, FastMoqAnalysisHelpers.FastMoqProvidersNamespace); updatedRoot = updatedRoot.AddAttributeLists(CreateAssemblyDefaultProviderAttribute(providerName)); return document.WithSyntaxRoot(updatedRoot); } @@ -1137,12 +1295,13 @@ private static async Task AddAssemblyDefaultProviderAsync(Document doc private static async Task AddAssemblyRegisteredDefaultProviderAsync(Document document, string providerName, CancellationToken cancellationToken) { var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false) as CompilationUnitSyntax; + var compilation = await document.Project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); if (root is null || HasAssemblyRegisteredDefaultProviderAttribute(root, providerName)) { return document; } - var updatedRoot = (CompilationUnitSyntax) AddUsingDirectivesIfMissing(root, [FastMoqAnalysisHelpers.FastMoqProvidersNamespace, FastMoqAnalysisHelpers.MoqProviderNamespace]); + var updatedRoot = (CompilationUnitSyntax) AddUsingDirectivesIfMissing(root, compilation, [FastMoqAnalysisHelpers.FastMoqProvidersNamespace, FastMoqAnalysisHelpers.MoqProviderNamespace]); updatedRoot = updatedRoot.AddAttributeLists(CreateAssemblyRegisteredDefaultProviderAttribute(providerName)); return document.WithSyntaxRoot(updatedRoot); } @@ -1258,24 +1417,68 @@ private static AttributeListSyntax CreateAssemblyRegisteredDefaultProviderAttrib private static SyntaxNode AddUsingDirectiveIfMissing(SyntaxNode root, string namespaceName) { - if (root is CompilationUnitSyntax compilationUnit && !compilationUnit.Usings.Any(@using => @using.Name?.ToString() == namespaceName)) + return AddUsingDirectiveIfMissing(root, compilation: null, namespaceName); + } + + private static SyntaxNode AddUsingDirectiveIfMissing(SyntaxNode root, Compilation? compilation, string namespaceName) + { + if (root is not CompilationUnitSyntax compilationUnit || IsNamespaceAlreadyImported(compilationUnit, compilation, namespaceName)) { - return compilationUnit.AddUsings(SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName))); + return root; } - return root; + return compilationUnit.AddUsings(SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName))); } private static SyntaxNode AddUsingDirectivesIfMissing(SyntaxNode root, IReadOnlyList namespaceNames) + { + return AddUsingDirectivesIfMissing(root, compilation: null, namespaceNames); + } + + private static SyntaxNode AddUsingDirectivesIfMissing(SyntaxNode root, Compilation? compilation, IReadOnlyList namespaceNames) { foreach (var namespaceName in namespaceNames) { - root = AddUsingDirectiveIfMissing(root, namespaceName); + root = AddUsingDirectiveIfMissing(root, compilation, namespaceName); } return root; } + private static bool IsNamespaceAlreadyImported(CompilationUnitSyntax compilationUnit, Compilation? compilation, string namespaceName) + { + if (compilationUnit.Usings.Any(@using => @using.Name?.ToString() == namespaceName)) + { + return true; + } + + if (compilation is null) + { + return false; + } + + foreach (var syntaxTree in compilation.SyntaxTrees) + { + if (syntaxTree.GetRoot() is not CompilationUnitSyntax candidateCompilationUnit) + { + continue; + } + + if (candidateCompilationUnit.Usings.Any(@using => @using.GlobalKeyword != default && @using.Name?.ToString() == namespaceName)) + { + return true; + } + } + + if (compilation.Options is CSharpCompilationOptions csharpCompilationOptions && + csharpCompilationOptions.Usings.Any(@using => string.Equals(@using, namespaceName, StringComparison.Ordinal))) + { + return true; + } + + return false; + } + private static InvocationExpressionSyntax? FindInvocationExpression(SyntaxNode root, TextSpan span) { return root.DescendantNodes() @@ -1300,6 +1503,243 @@ private static IReadOnlyList ParseReplacementStatements(IReadOn return parsedStatements; } + private static bool TryBuildDirectMockerTestBaseInheritanceFix( + ClassDeclarationSyntax classDeclaration, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out DirectMockerTestBaseInheritanceFix fix) + { + fix = default; + + if (!FastMoqAnalysisHelpers.TryGetDirectMockerTestBaseInheritanceCandidate(classDeclaration, semanticModel, cancellationToken, out var candidate) || + !candidate.IsFixable || + candidate.HelperMember is not IFieldSymbol helperField || + helperField.DeclaringSyntaxReferences.SingleOrDefault()?.GetSyntax(cancellationToken) is not VariableDeclaratorSyntax helperVariable || + helperVariable.Parent?.Parent is not FieldDeclarationSyntax helperFieldDeclaration || + helperFieldDeclaration.Declaration.Variables.Count != 1 || + !TryGetHelperAliasMap(candidate.HelperTypeDeclaration, out var aliasMap)) + { + return false; + } + + var memberAccessReplacements = new List(); + var membersToRemove = new List(); + var statementsToRemove = new List(); + + foreach (var member in classDeclaration.Members) + { + if (member == candidate.HelperTypeDeclaration || member == helperFieldDeclaration) + { + continue; + } + + if (TryGetRedundantOuterHelperAliasMemberToRemove(member, helperField, aliasMap, semanticModel, cancellationToken)) + { + membersToRemove.Add(member); + continue; + } + + foreach (var helperReference in member.DescendantNodesAndSelf().OfType()) + { + if (!TryGetReferencedHelperExpression(helperReference, helperField, semanticModel, cancellationToken, out var referencedHelperExpression)) + { + continue; + } + + if (helperReference.Parent is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression == helperReference) + { + if (!aliasMap.TryGetValue(memberAccess.Name.Identifier.ValueText, out var baseMemberName)) + { + return false; + } + + memberAccessReplacements.Add(new DirectMockerTestBaseInheritanceReplacement(memberAccess, $"base.{baseMemberName}")); + continue; + } + + if (helperReference.Parent is AssignmentExpressionSyntax assignmentExpression && + assignmentExpression.Left == referencedHelperExpression && + assignmentExpression.Parent is ExpressionStatementSyntax expressionStatement && + TryIsHelperConstructionAssignment(assignmentExpression.Right, candidate.HelperType, semanticModel, cancellationToken)) + { + statementsToRemove.Add(expressionStatement); + continue; + } + + return false; + } + } + + fix = new DirectMockerTestBaseInheritanceFix( + classDeclaration, + candidate.HelperTypeDeclaration, + helperFieldDeclaration, + candidate.TargetType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat), + memberAccessReplacements, + membersToRemove, + statementsToRemove); + return true; + } + + private static bool TryGetRedundantOuterHelperAliasMemberToRemove( + MemberDeclarationSyntax member, + IFieldSymbol helperField, + IReadOnlyDictionary aliasMap, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + if (member is not PropertyDeclarationSyntax propertyDeclaration || + propertyDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword) || + propertyDeclaration.Identifier.ValueText is not "Component" and not "Mocks" || + !FastMoqAnalysisHelpers.TryGetPropertyReturnExpression(propertyDeclaration, out var expression)) + { + return false; + } + + expression = FastMoqAnalysisHelpers.Unwrap(expression); + if (expression is not MemberAccessExpressionSyntax memberAccess || + !TryGetReferencedHelperExpression(memberAccess.Expression, helperField, semanticModel, cancellationToken, out _) || + !aliasMap.TryGetValue(memberAccess.Name.Identifier.ValueText, out var baseMemberName)) + { + return false; + } + + return string.Equals(baseMemberName, propertyDeclaration.Identifier.ValueText, StringComparison.Ordinal); + } + + private static bool TryGetHelperAliasMap(TypeDeclarationSyntax helperTypeDeclaration, out Dictionary aliasMap) + { + aliasMap = new Dictionary(StringComparer.Ordinal); + + foreach (var propertyDeclaration in helperTypeDeclaration.Members.OfType()) + { + if (!FastMoqAnalysisHelpers.TryGetPropertyReturnExpression(propertyDeclaration, out var expression)) + { + return false; + } + + expression = FastMoqAnalysisHelpers.Unwrap(expression); + string? baseMemberName = expression switch + { + IdentifierNameSyntax identifierName when identifierName.Identifier.ValueText is "Component" or "Mocks" => identifierName.Identifier.ValueText, + MemberAccessExpressionSyntax memberAccess when memberAccess.Expression is ThisExpressionSyntax && memberAccess.Name.Identifier.ValueText is "Component" or "Mocks" => memberAccess.Name.Identifier.ValueText, + _ => null, + }; + + if (baseMemberName is null) + { + return false; + } + + aliasMap[propertyDeclaration.Identifier.ValueText] = baseMemberName; + } + + return aliasMap.Count > 0; + } + + private static bool TryIsHelperConstructionAssignment(ExpressionSyntax expression, INamedTypeSymbol helperType, SemanticModel semanticModel, CancellationToken cancellationToken) + { + if (expression is ObjectCreationExpressionSyntax objectCreationExpression) + { + if (objectCreationExpression.Initializer is not null) + { + return false; + } + } + else if (expression is ImplicitObjectCreationExpressionSyntax implicitObjectCreationExpression) + { + if (implicitObjectCreationExpression.Initializer is not null) + { + return false; + } + } + else + { + return false; + } + + var type = semanticModel.GetTypeInfo(expression, cancellationToken).Type as INamedTypeSymbol; + return type is not null && SymbolEqualityComparer.Default.Equals(type, helperType); + } + + private static bool TryGetReferencedHelperExpression(ExpressionSyntax expression, IFieldSymbol helperField, SemanticModel semanticModel, CancellationToken cancellationToken, out ExpressionSyntax referencedHelperExpression) + { + if (expression is IdentifierNameSyntax identifierName) + { + if (identifierName.Parent is MemberAccessExpressionSyntax parentMemberAccess && + parentMemberAccess.Name == identifierName) + { + referencedHelperExpression = default!; + return false; + } + + if (SymbolEqualityComparer.Default.Equals(semanticModel.GetSymbolInfo(identifierName, cancellationToken).Symbol, helperField)) + { + referencedHelperExpression = identifierName; + return true; + } + } + + if (expression is MemberAccessExpressionSyntax memberAccess && + SymbolEqualityComparer.Default.Equals(semanticModel.GetSymbolInfo(memberAccess, cancellationToken).Symbol, helperField)) + { + referencedHelperExpression = expression; + return true; + } + + referencedHelperExpression = default!; + return false; + } + + private readonly struct DirectMockerTestBaseInheritanceFix + { + public DirectMockerTestBaseInheritanceFix( + ClassDeclarationSyntax outerClassDeclaration, + TypeDeclarationSyntax helperTypeDeclaration, + FieldDeclarationSyntax helperFieldDeclaration, + string targetTypeName, + IReadOnlyList memberAccessReplacements, + IReadOnlyList membersToRemove, + IReadOnlyList statementsToRemove) + { + OuterClassDeclaration = outerClassDeclaration; + HelperTypeDeclaration = helperTypeDeclaration; + HelperFieldDeclaration = helperFieldDeclaration; + TargetTypeName = targetTypeName; + MemberAccessReplacements = memberAccessReplacements; + MembersToRemove = membersToRemove; + StatementsToRemove = statementsToRemove; + } + + public ClassDeclarationSyntax OuterClassDeclaration { get; } + + public TypeDeclarationSyntax HelperTypeDeclaration { get; } + + public FieldDeclarationSyntax HelperFieldDeclaration { get; } + + public string TargetTypeName { get; } + + public IReadOnlyList MemberAccessReplacements { get; } + + public IReadOnlyList MembersToRemove { get; } + + public IReadOnlyList StatementsToRemove { get; } + } + + private readonly struct DirectMockerTestBaseInheritanceReplacement + { + public DirectMockerTestBaseInheritanceReplacement(MemberAccessExpressionSyntax memberAccess, string replacementText) + { + MemberAccess = memberAccess; + ReplacementText = replacementText; + } + + public MemberAccessExpressionSyntax MemberAccess { get; } + + public string ReplacementText { get; } + } + private static bool TryBuildProviderNeutralHttpHelperEdit(InvocationExpressionSyntax invocationExpression, SemanticModel semanticModel, CancellationToken cancellationToken, out ProviderNeutralHttpHelperEdit edit) { edit = default; diff --git a/FastMoq.Analyzers/DiagnosticDescriptors.cs b/FastMoq.Analyzers/DiagnosticDescriptors.cs index 5335f2e9..dd785bad 100644 --- a/FastMoq.Analyzers/DiagnosticDescriptors.cs +++ b/FastMoq.Analyzers/DiagnosticDescriptors.cs @@ -335,5 +335,23 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Info, isEnabledByDefault: true, description: "Some provider-first helper replacements live in split FastMoq packages such as FastMoq.Web or FastMoq.AzureFunctions. When the helper package is missing, guide the user to the package and namespace instead of surfacing a non-actionable rewrite diagnostic."); + + public static readonly DiagnosticDescriptor DirectMockerTestBaseInheritance = new( + DiagnosticIds.DirectMockerTestBaseInheritance, + "Prefer inheritance over MockerTestBase helper composition", + "Nested helper '{2}' composes MockerTestBase<{1}> through an instance wrapper. Prefer inheritance in test class '{0}' directly or through a dedicated shared test base.", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "When a nested helper only wraps MockerTestBase for one test class, prefer inheriting from MockerTestBase on the test class itself or extracting a shared base when multiple tests need the same setup. Keep the helper only when it adds meaningful behavior beyond forwarding the inherited FastMoq surface."); + + public static readonly DiagnosticDescriptor UnnecessaryMockerTestBaseHelperIndirection = new( + DiagnosticIds.UnnecessaryMockerTestBaseHelperIndirection, + "Avoid unnecessary MockerTestBase helper indirection", + "Helper member '{0}' only forwards to inherited MockerTestBase behavior through '{1}'. Prefer the inherited surface directly when the wrapper adds no meaningful behavior.", + Category, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "When a helper property or method only forwards to inherited MockerTestBase members such as Component or Mocks, prefer using the inherited member directly instead of keeping an extra wrapper. Keep the wrapper when it adds readability, setup, naming value, or other behavior that the inherited member does not provide."); } } \ No newline at end of file diff --git a/FastMoq.Analyzers/DiagnosticIds.cs b/FastMoq.Analyzers/DiagnosticIds.cs index 95907ccc..97d95109 100644 --- a/FastMoq.Analyzers/DiagnosticIds.cs +++ b/FastMoq.Analyzers/DiagnosticIds.cs @@ -184,5 +184,15 @@ public static class DiagnosticIds /// Prefer provider-neutral logger callback capture over provider-specific ILogger.Log setup when the callback only needs normalized output. /// public const string PreferSetupLoggerCallbackHelper = "FMOQ0036"; + + /// + /// Prefer inheritance over thin local helper-instance composition around MockerTestBase<T>. + /// + public const string DirectMockerTestBaseInheritance = "FMOQ0037"; + + /// + /// Avoid unnecessary helper indirection around inherited MockerTestBase<T> members. + /// + public const string UnnecessaryMockerTestBaseHelperIndirection = "FMOQ0038"; } } \ No newline at end of file diff --git a/FastMoq.Analyzers/FastMoqAnalysisHelpers.cs b/FastMoq.Analyzers/FastMoqAnalysisHelpers.cs index 07d5ebe4..2b86cd75 100644 --- a/FastMoq.Analyzers/FastMoqAnalysisHelpers.cs +++ b/FastMoq.Analyzers/FastMoqAnalysisHelpers.cs @@ -67,6 +67,50 @@ internal enum FastMockVerifyWrapperKind ProviderSpecific, } + internal enum MockerTestBaseHelperCompositionFixBlockReason + { + None, + UnsupportedHooks, + UnsupportedForNow, + } + + internal readonly struct MockerTestBaseHelperCompositionCandidate + { + public MockerTestBaseHelperCompositionCandidate( + TypeDeclarationSyntax outerTypeDeclaration, + INamedTypeSymbol outerType, + TypeDeclarationSyntax helperTypeDeclaration, + INamedTypeSymbol helperType, + ISymbol helperMember, + INamedTypeSymbol targetType, + MockerTestBaseHelperCompositionFixBlockReason fixBlockReason) + { + OuterTypeDeclaration = outerTypeDeclaration; + OuterType = outerType; + HelperTypeDeclaration = helperTypeDeclaration; + HelperType = helperType; + HelperMember = helperMember; + TargetType = targetType; + FixBlockReason = fixBlockReason; + } + + public TypeDeclarationSyntax OuterTypeDeclaration { get; } + + public INamedTypeSymbol OuterType { get; } + + public TypeDeclarationSyntax HelperTypeDeclaration { get; } + + public INamedTypeSymbol HelperType { get; } + + public ISymbol HelperMember { get; } + + public INamedTypeSymbol TargetType { get; } + + public MockerTestBaseHelperCompositionFixBlockReason FixBlockReason { get; } + + public bool IsFixable => FixBlockReason == MockerTestBaseHelperCompositionFixBlockReason.None; + } + internal readonly struct ProviderRegistrationCandidate { public ProviderRegistrationCandidate(string providerName, ITypeSymbol? providerType) @@ -4416,20 +4460,35 @@ private static bool TryGetContainingMockerTestBaseTargetType(SyntaxNode node, Se return false; } - for (var current = containingType.BaseType; current is not null; current = current.BaseType) + return TryGetMockerTestBaseTargetType(containingType, out targetType); + } + + public static bool TryGetDirectMockerTestBaseInheritanceCandidate(TypeDeclarationSyntax typeDeclaration, SemanticModel semanticModel, CancellationToken cancellationToken, out MockerTestBaseHelperCompositionCandidate candidate) + { + semanticModel = GetSemanticModelForNode(typeDeclaration, semanticModel); + + if (semanticModel.GetDeclaredSymbol(typeDeclaration, cancellationToken) is not INamedTypeSymbol outerType || + outerType.TypeKind != TypeKind.Class || + outerType.IsStatic || + (outerType.BaseType is not null && outerType.BaseType.SpecialType != SpecialType.System_Object) || + !TryGetNestedMockerTestBaseHelperMember(typeDeclaration, semanticModel, cancellationToken, outerType, out var helperMember, out var helperTypeDeclaration, out var helperType) || + !TryGetDirectMockerTestBaseTargetType(helperType, out var targetType) || + !HelperHasThinWrapperShape(helperTypeDeclaration, out var hasUnsupportedHooks) || + !TryGetHelperCompositionFixBlockReason(helperTypeDeclaration, hasUnsupportedHooks, out var fixBlockReason)) { - if (current.OriginalDefinition.MetadataName == FASTMOQ_MOCKER_TEST_BASE_METADATA_NAME && - current.OriginalDefinition.ContainingNamespace.ToDisplayString() == "FastMoq" && - current.TypeArguments.Length == 1 && - current.TypeArguments[0] is INamedTypeSymbol namedTargetType) - { - targetType = namedTargetType; - return true; - } + candidate = default; + return false; } - targetType = default!; - return false; + candidate = new MockerTestBaseHelperCompositionCandidate( + typeDeclaration, + outerType, + helperTypeDeclaration, + helperType, + helperMember, + targetType, + fixBlockReason); + return true; } public static bool IsMoqVerifyMethod(IMethodSymbol method) @@ -4523,6 +4582,287 @@ public static bool IsIFileSystemType(ITypeSymbol? type) return type?.ToDisplayString() == IFileSystemTypeName; } + private static bool TryGetMockerTestBaseTargetType(INamedTypeSymbol typeSymbol, out INamedTypeSymbol targetType) + { + for (var current = typeSymbol.BaseType; current is not null; current = current.BaseType) + { + if (TryGetMockerTestBaseTargetTypeCore(current, out targetType)) + { + return true; + } + } + + targetType = default!; + return false; + } + + private static bool TryGetDirectMockerTestBaseTargetType(INamedTypeSymbol typeSymbol, out INamedTypeSymbol targetType) + { + return TryGetMockerTestBaseTargetTypeCore(typeSymbol.BaseType, out targetType); + } + + private static bool TryGetMockerTestBaseTargetTypeCore(INamedTypeSymbol? typeSymbol, out INamedTypeSymbol targetType) + { + if (typeSymbol is not null && + typeSymbol.OriginalDefinition.MetadataName == FASTMOQ_MOCKER_TEST_BASE_METADATA_NAME && + typeSymbol.OriginalDefinition.ContainingNamespace.ToDisplayString() == "FastMoq" && + typeSymbol.TypeArguments.Length == 1 && + typeSymbol.TypeArguments[0] is INamedTypeSymbol namedTargetType) + { + targetType = namedTargetType; + return true; + } + + targetType = default!; + return false; + } + + private static bool TryGetNestedMockerTestBaseHelperMember( + TypeDeclarationSyntax outerTypeDeclaration, + SemanticModel semanticModel, + CancellationToken cancellationToken, + INamedTypeSymbol outerType, + out ISymbol helperMember, + out TypeDeclarationSyntax helperTypeDeclaration, + out INamedTypeSymbol helperType) + { + var nestedTypes = outerTypeDeclaration.Members + .OfType() + .Select(typeSyntax => (TypeSyntax: typeSyntax, TypeSymbol: semanticModel.GetDeclaredSymbol(typeSyntax, cancellationToken) as INamedTypeSymbol)) + .Where(item => item.TypeSymbol is not null) + .Select(item => (item.TypeSyntax, TypeSymbol: item.TypeSymbol!)) + .ToDictionary(item => item.TypeSymbol, item => item.TypeSyntax, SymbolEqualityComparer.Default); + + if (nestedTypes.Count == 0) + { + helperMember = default!; + helperTypeDeclaration = default!; + helperType = default!; + return false; + } + + var candidates = new List<(ISymbol Member, TypeDeclarationSyntax HelperSyntax, INamedTypeSymbol HelperType)>(); + + foreach (var fieldDeclaration in outerTypeDeclaration.Members.OfType()) + { + if (fieldDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword) || + semanticModel.GetTypeInfo(fieldDeclaration.Declaration.Type, cancellationToken).Type is not INamedTypeSymbol fieldType || + !SymbolEqualityComparer.Default.Equals(fieldType.ContainingType, outerType) || + !nestedTypes.TryGetValue(fieldType, out var nestedTypeSyntax)) + { + continue; + } + + foreach (var variable in fieldDeclaration.Declaration.Variables) + { + if (semanticModel.GetDeclaredSymbol(variable, cancellationToken) is IFieldSymbol fieldSymbol && !fieldSymbol.IsStatic) + { + candidates.Add((fieldSymbol, nestedTypeSyntax, fieldType)); + } + } + } + + foreach (var propertyDeclaration in outerTypeDeclaration.Members.OfType()) + { + if (propertyDeclaration.Modifiers.Any(SyntaxKind.StaticKeyword) || + semanticModel.GetDeclaredSymbol(propertyDeclaration, cancellationToken) is not IPropertySymbol propertySymbol || + propertySymbol.IsStatic || + semanticModel.GetTypeInfo(propertyDeclaration.Type, cancellationToken).Type is not INamedTypeSymbol propertyType || + !SymbolEqualityComparer.Default.Equals(propertyType.ContainingType, outerType) || + !nestedTypes.TryGetValue(propertyType, out var nestedTypeSyntax)) + { + continue; + } + + candidates.Add((propertySymbol, nestedTypeSyntax, propertyType)); + } + + var distinctCandidates = candidates + .Where(candidate => HasHelperMemberUsage(outerTypeDeclaration, candidate.Member, semanticModel, cancellationToken)) + .Distinct(new HelperMemberCandidateComparer()) + .ToArray(); + + if (distinctCandidates.Length != 1) + { + helperMember = default!; + helperTypeDeclaration = default!; + helperType = default!; + return false; + } + + helperMember = distinctCandidates[0].Member; + helperTypeDeclaration = distinctCandidates[0].HelperSyntax; + helperType = distinctCandidates[0].HelperType; + return true; + } + + private static bool HasHelperMemberUsage(TypeDeclarationSyntax outerTypeDeclaration, ISymbol helperMember, SemanticModel semanticModel, CancellationToken cancellationToken) + { + foreach (var member in outerTypeDeclaration.Members) + { + if (member is TypeDeclarationSyntax) + { + continue; + } + + foreach (var memberAccess in member.DescendantNodesAndSelf().OfType()) + { + if (semanticModel.GetSymbolInfo(memberAccess.Expression, cancellationToken).Symbol is { } accessSymbol && + SymbolEqualityComparer.Default.Equals(accessSymbol, helperMember)) + { + return true; + } + } + } + + return false; + } + + private static bool HelperHasThinWrapperShape(TypeDeclarationSyntax helperTypeDeclaration, out bool hasUnsupportedHooks) + { + hasUnsupportedHooks = false; + + foreach (var member in helperTypeDeclaration.Members) + { + switch (member) + { + case ConstructorDeclarationSyntax: + continue; + + case PropertyDeclarationSyntax propertyDeclaration: + { + var propertyName = propertyDeclaration.Identifier.ValueText; + if (propertyName is "ComponentConstructorParameterTypes" or "CreateComponentAction" or "CreatedComponentAction") + { + hasUnsupportedHooks = true; + continue; + } + + if (!IsThinHelperAliasProperty(propertyDeclaration)) + { + return false; + } + + continue; + } + + case MethodDeclarationSyntax: + case FieldDeclarationSyntax: + case EventDeclarationSyntax: + case EventFieldDeclarationSyntax: + case IndexerDeclarationSyntax: + case OperatorDeclarationSyntax: + case ConversionOperatorDeclarationSyntax: + case DestructorDeclarationSyntax: + case TypeDeclarationSyntax: + return false; + + default: + return false; + } + } + + return true; + } + + private static bool TryGetHelperCompositionFixBlockReason( + TypeDeclarationSyntax helperTypeDeclaration, + bool hasUnsupportedHooks, + out MockerTestBaseHelperCompositionFixBlockReason fixBlockReason) + { + if (hasUnsupportedHooks) + { + fixBlockReason = MockerTestBaseHelperCompositionFixBlockReason.UnsupportedHooks; + return true; + } + + foreach (var constructorDeclaration in helperTypeDeclaration.Members.OfType()) + { + if (constructorDeclaration.Initializer is not null) + { + fixBlockReason = MockerTestBaseHelperCompositionFixBlockReason.UnsupportedForNow; + return true; + } + + if (constructorDeclaration.ExpressionBody is not null || + constructorDeclaration.Body?.Statements.Count > 0) + { + fixBlockReason = MockerTestBaseHelperCompositionFixBlockReason.UnsupportedForNow; + return true; + } + } + + fixBlockReason = MockerTestBaseHelperCompositionFixBlockReason.None; + return true; + } + + private static bool IsThinHelperAliasProperty(PropertyDeclarationSyntax propertyDeclaration) + { + if (!TryGetPropertyReturnExpression(propertyDeclaration, out var expression)) + { + return false; + } + + expression = Unwrap(expression); + return expression switch + { + IdentifierNameSyntax identifierName => identifierName.Identifier.ValueText is "Component" or "Mocks", + MemberAccessExpressionSyntax memberAccess when memberAccess.Expression is ThisExpressionSyntax => memberAccess.Name.Identifier.ValueText is "Component" or "Mocks", + _ => false, + }; + } + + internal static bool TryGetPropertyReturnExpression(PropertyDeclarationSyntax propertyDeclaration, out ExpressionSyntax expression) + { + if (propertyDeclaration.ExpressionBody is not null) + { + expression = propertyDeclaration.ExpressionBody.Expression; + return true; + } + + if (propertyDeclaration.AccessorList is null) + { + expression = default!; + return false; + } + + var accessors = propertyDeclaration.AccessorList.Accessors; + if (accessors.Count != 1 || !accessors[0].Keyword.IsKind(SyntaxKind.GetKeyword)) + { + expression = default!; + return false; + } + + var getter = accessors[0]; + if (getter.ExpressionBody is not null) + { + expression = getter.ExpressionBody.Expression; + return true; + } + + if (getter.Body?.Statements.Count == 1 && getter.Body.Statements[0] is ReturnStatementSyntax returnStatement && returnStatement.Expression is not null) + { + expression = returnStatement.Expression; + return true; + } + + expression = default!; + return false; + } + + private sealed class HelperMemberCandidateComparer : IEqualityComparer<(ISymbol Member, TypeDeclarationSyntax HelperSyntax, INamedTypeSymbol HelperType)> + { + public bool Equals((ISymbol Member, TypeDeclarationSyntax HelperSyntax, INamedTypeSymbol HelperType) x, (ISymbol Member, TypeDeclarationSyntax HelperSyntax, INamedTypeSymbol HelperType) y) + { + return SymbolEqualityComparer.Default.Equals(x.Member, y.Member); + } + + public int GetHashCode((ISymbol Member, TypeDeclarationSyntax HelperSyntax, INamedTypeSymbol HelperType) obj) + { + return SymbolEqualityComparer.Default.GetHashCode(obj.Member); + } + } + public static bool IsMockFileSystemType(ITypeSymbol? type) { return type?.ToDisplayString() == MockFileSystemTypeName; diff --git a/docs/migration/README.md b/docs/migration/README.md index 3077bb67..35fc2c01 100644 --- a/docs/migration/README.md +++ b/docs/migration/README.md @@ -115,6 +115,8 @@ This is the full public analyzer catalog for the current v4 line. Use it as the | `FMOQ0034` | Inside provider-first `Verify(...)`, replace mechanical `It.*` matchers with `FastArg` equivalents so the assertion stays provider-neutral | | `FMOQ0035` | Flag remaining Moq-specific matchers inside provider-first `Verify(...)` when no direct `FastArg` rewrite exists | | `FMOQ0036` | Prefer `SetupLoggerCallback(...)` over tracked `ILogger.Log` setup when the callback only needs normalized message or exception output | +| `FMOQ0037` | Prefer direct inheritance or a shared inherited base over thin local helper-instance composition around `MockerTestBase` | +| `FMOQ0038` | Flag thin helper indirection that only forwards to inherited `MockerTestBase` members without adding meaningful behavior | For a successful v4 migration, use this boundary: