diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7968ad46..0e1956a9 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -56,14 +56,25 @@ <PropertyGroup> <SignAssembly>false</SignAssembly> <DelaySign>false</DelaySign> - <TreatWarningsAsErrors>false</TreatWarningsAsErrors> - <NoWarn>436,NU5125</NoWarn> - <Configurations>Debug;Release;ReleaseForDeploy</Configurations> - <Platforms>AnyCPU</Platforms> </PropertyGroup> + <!-- Analyzers --> + <PropertyGroup> + <GenerateDocumentationFile>true</GenerateDocumentationFile> + <NoWarn>$(NoWarn),1573,1574,1591,1712,1723</NoWarn> + </PropertyGroup> + + <!-- Runs the analyzers (in memory) on build --> + <ItemGroup Condition="'$(IsTestProject)'!='true' AND '$(IsRoslynComponent)'!='true'"> + <PackageReference Include="SaaStack.Tools.Analyzers.Core" Version="1.0.0" PrivateAssets="all" /> + </ItemGroup> + <!-- Build configurations --> <PropertyGroup> + <TreatWarningsAsErrors>false</TreatWarningsAsErrors> + <NoWarn>$(NoWarn),436,NU5125</NoWarn> + <Configurations>Debug;Release;ReleaseForDeploy</Configurations> + <Platforms>AnyCPU</Platforms> <Configuration Condition="'$(Configuration)' == ''">Debug</Configuration> <Platform Condition="'$(Platform)' == ''">AnyCPU</Platform> </PropertyGroup> @@ -95,11 +106,4 @@ <WarningLevel>4</WarningLevel> </PropertyGroup> - <!-- Runs the analyzers (in memory) on build --> - <ItemGroup Condition="'$(IsTestProject)'!='true' AND '$(IsRoslynComponent)'!='true'"> - <PackageReference Include="SaaStack.Tools.Analyzers.Core" Version="1.0.0"> - <IncludeAssets>analyzers</IncludeAssets> - </PackageReference> - </ItemGroup> - </Project> diff --git a/src/Infrastructure.WebApi.Common/ApiResult.cs b/src/Infrastructure.WebApi.Common/ApiResult.cs new file mode 100644 index 00000000..a22a4e78 --- /dev/null +++ b/src/Infrastructure.WebApi.Common/ApiResult.cs @@ -0,0 +1,51 @@ +using Common; +using Infrastructure.WebApi.Interfaces; + +namespace Infrastructure.WebApi.Common; + +/// <summary> +/// Defines an callback that relates a <see cref="PostResult{TResponse}" /> containing a +/// <see cref="TResource" /> +/// </summary> +// ReSharper disable once UnusedTypeParameter +public delegate Result<PostResult<TResponse>, Error> ApiPostResult<TResource, TResponse>() + where TResource : class where TResponse : IWebResponse; + +/// <summary> +/// Defines an callback that relates a <see cref="TResponse" /> containing a +/// <see cref="TResource" /> +/// </summary> +// ReSharper disable once UnusedTypeParameter +public delegate Result<TResponse, Error> ApiResult<TResource, TResponse>() + where TResource : class where TResponse : IWebResponse; + +/// <summary> +/// Defines an callback that relates a <see cref="EmptyResponse" /> +/// </summary> +public delegate Result<EmptyResponse, Error> ApiEmptyResult(); + +/// <summary> +/// Provides a container with a <see cref="TResponse" /> and other attributes describing a +/// </summary> +/// <typeparam name="TResponse"></typeparam> +public class PostResult<TResponse> + where TResponse : IWebResponse +{ + public PostResult(TResponse response, string? location = null) + { + Response = response; + Location = location; + } + + public string? Location { get; } + + public TResponse Response { get; } + + /// <summary> + /// Converts the <see cref="response" /> into a <see cref="PostResult{TResponse}" /> + /// </summary> + public static implicit operator PostResult<TResponse>(TResponse response) + { + return new PostResult<TResponse>(response); + } +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Common/HandlerExtensions.cs b/src/Infrastructure.WebApi.Common/HandlerExtensions.cs index 83b8cd4a..f40c7cc5 100644 --- a/src/Infrastructure.WebApi.Common/HandlerExtensions.cs +++ b/src/Infrastructure.WebApi.Common/HandlerExtensions.cs @@ -5,53 +5,6 @@ namespace Infrastructure.WebApi.Common; -/// <summary> -/// Defines an callback that relates a <see cref="PostResult{TResponse}" /> containing a -/// <see cref="TResource" /> -/// </summary> -// ReSharper disable once UnusedTypeParameter -public delegate Result<PostResult<TResponse>, Error> ApiPostResult<TResource, TResponse>() - where TResource : class where TResponse : IWebResponse; - -/// <summary> -/// Defines an callback that relates a <see cref="TResponse" /> containing a -/// <see cref="TResource" /> -/// </summary> -// ReSharper disable once UnusedTypeParameter -public delegate Result<TResponse, Error> ApiResult<TResource, TResponse>() - where TResource : class where TResponse : IWebResponse; - -/// <summary> -/// Defines an callback that relates a <see cref="EmptyResponse" /> -/// </summary> -public delegate Result<EmptyResponse, Error> ApiEmptyResult(); - -/// <summary> -/// Provides a container with a <see cref="TResponse" /> and other attributes describing a -/// </summary> -/// <typeparam name="TResponse"></typeparam> -public class PostResult<TResponse> - where TResponse : IWebResponse -{ - public PostResult(TResponse response, string? location = null) - { - Response = response; - Location = location; - } - - public string? Location { get; } - - public TResponse Response { get; } - - /// <summary> - /// Converts the <see cref="response" /> into a <see cref="PostResult{TResponse}" /> - /// </summary> - public static implicit operator PostResult<TResponse>(TResponse response) - { - return new PostResult<TResponse>(response); - } -} - public static class HandlerExtensions { /// <summary> diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 097468e4..91199146 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -5,6 +5,7 @@ <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=ArrangeNamespaceBody/@EntryIndexedValue">WARNING</s:String> <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=ClassNeverInstantiated_002EGlobal/@EntryIndexedValue">WARNING</s:String> + <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=DuplicateResource/@EntryIndexedValue">DO_NOT_SHOW</s:String> <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=SeparateLocalFunctionsWithJumpStatement/@EntryIndexedValue">WARNING</s:String> <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=UnusedType_002EGlobal/@EntryIndexedValue">WARNING</s:String> <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/BRACES_FOR_FOR/@EntryValue">Required</s:String> @@ -133,6 +134,7 @@ </And> </Entry.Match> <Entry.SortBy> + <Access/> <Name /> </Entry.SortBy> </Entry> @@ -159,6 +161,7 @@ </Entry.Match> <Entry.SortBy> <Kind Order="Constant Field" /> + <Access/> <Name /> </Entry.SortBy> </Entry> @@ -172,6 +175,7 @@ </And> </Entry.Match> <Entry.SortBy> + <Access/> <Readonly /> <Name /> </Entry.SortBy> @@ -373,10 +377,12 @@ public void When$condition$_Then$outcome$() <s:Boolean x:Key="/Default/UserDictionary/Words/=aname/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Anding/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=anid/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=anotherresource/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=anothervalue/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=apattern/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=aproperty/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=areplacement/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=aresource/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=aresourceref/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=asort/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=asortfield/@EntryIndexedValue">True</s:Boolean> diff --git a/src/Tools.Analyzers.Core.UnitTests/MissingDocsAnalyzerSpec.cs b/src/Tools.Analyzers.Core.UnitTests/MissingDocsAnalyzerSpec.cs index 4ad30fdb..79bd4047 100644 --- a/src/Tools.Analyzers.Core.UnitTests/MissingDocsAnalyzerSpec.cs +++ b/src/Tools.Analyzers.Core.UnitTests/MissingDocsAnalyzerSpec.cs @@ -1,5 +1,7 @@ +extern alias Analyzers; using JetBrains.Annotations; using Xunit; +using MissingDocsAnalyzer = Analyzers::Tools.Analyzers.Core.MissingDocsAnalyzer; namespace Tools.Analyzers.Core.UnitTests; @@ -7,7 +9,7 @@ namespace Tools.Analyzers.Core.UnitTests; public class MissingDocsAnalyzerSpec { [Trait("Category", "Unit")] - public class GivenAType + public class GivenRuleSas001 { [Fact] public async Task WhenInJetbrainsAnnotationsNamespace_ThenNoAlert() @@ -17,7 +19,7 @@ public class AClass { }"; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -28,7 +30,7 @@ public class AClass { }"; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -36,7 +38,7 @@ public async Task WhenPublicDelegate_ThenAlerts() { const string input = @"public delegate void ADelegate();"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 22, "ADelegate"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 22, "ADelegate"); } [Fact] @@ -44,7 +46,7 @@ public async Task WhenInternalDelegate_ThenAlerts() { const string input = @"internal delegate void ADelegate();"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 24, "ADelegate"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 24, "ADelegate"); } [Fact] @@ -54,7 +56,7 @@ public async Task WhenPublicInterface_ThenAlerts() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 18, "AnInterface"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 18, "AnInterface"); } [Fact] @@ -64,7 +66,7 @@ public async Task WhenInternalInterface_ThenAlerts() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 20, "AnInterface"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 20, "AnInterface"); } [Fact] @@ -74,7 +76,7 @@ public async Task WhenPublicEnum_ThenAlerts() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 13, "AnEnum"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 13, "AnEnum"); } [Fact] @@ -84,7 +86,7 @@ public async Task WhenInternalEnum_ThenAlerts() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 15, "AnEnum"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 15, "AnEnum"); } [Fact] @@ -94,7 +96,7 @@ public async Task WhenPublicStruct_ThenAlerts() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 15, "AStruct"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 15, "AStruct"); } [Fact] @@ -104,7 +106,7 @@ public async Task WhenInternalStruct_ThenAlerts() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 17, "AStruct"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 17, "AStruct"); } [Fact] @@ -114,7 +116,7 @@ public async Task WhenPublicReadOnlyStruct_ThenAlerts() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 24, "AStruct"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 24, "AStruct"); } [Fact] @@ -124,7 +126,7 @@ public async Task WhenInternalReadOnlyStruct_ThenAlerts() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 26, "AStruct"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 26, "AStruct"); } [Fact] @@ -134,7 +136,7 @@ public async Task WhenPublicRecord_ThenAlerts() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 15, "ARecord"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 15, "ARecord"); } [Fact] @@ -144,7 +146,7 @@ public async Task WhenInternalRecord_ThenAlerts() { }"; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 17, "ARecord"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 17, "ARecord"); } [Fact] @@ -155,7 +157,7 @@ public async Task WhenPublicStaticClass_ThenNoAlert() } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -166,7 +168,7 @@ public async Task WhenInternalStaticClass_ThenNoAlert() } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -180,7 +182,7 @@ public static class AClass2 } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -198,7 +200,7 @@ private static class AClass2 } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -216,7 +218,7 @@ public class AClass2 } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 7, 18, "AClass2"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 7, 18, "AClass2"); } [Fact] @@ -234,7 +236,7 @@ private class AClass2 } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -245,7 +247,7 @@ public async Task WhenPublicClassNoSummary_ThenAlerts() } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 14); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 14, "AClass"); } [Fact] @@ -256,7 +258,7 @@ public async Task WhenInternalClassNoSummary_ThenAlerts() } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 16); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 1, 16, "AClass"); } [Fact] @@ -268,7 +270,7 @@ public class AClass } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 2, 14); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 2, 14, "AClass"); } [Fact] @@ -281,7 +283,7 @@ public class AClass } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 3, 14); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 3, 14, "AClass"); } [Fact] @@ -296,7 +298,7 @@ public class AClass } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 5, 14); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 5, 14, "AClass"); } [Fact] @@ -311,7 +313,7 @@ public class AClass } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 5, 14); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 5, 14, "AClass"); } [Fact] @@ -326,7 +328,7 @@ public class AClass } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 5, 14); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 5, 14, "AClass"); } [Fact] @@ -341,7 +343,7 @@ public class AClass } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 5, 14); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas001, input, 5, 14, "AClass"); } [Fact] @@ -356,12 +358,12 @@ public class AClass } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } } [Trait("Category", "Unit")] - public class GivenAMethod + public class GivenRuleSas002 { [Fact] public async Task WhenInJetbrainsAnnotationsNamespace_ThenNoAlert() @@ -372,7 +374,7 @@ public static class AClass public static void AMethod(){} }"; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -384,7 +386,7 @@ public static class AClass public static void AMethod(){} }"; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -396,7 +398,7 @@ public static void AMethod1(){} public static void AMethod2(this string value){} }"; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -408,7 +410,7 @@ public static void AMethod(){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.ExtensionMethodDiagnosticId, input, 3, 24, "AMethod"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas002, input, 3, 24, "AMethod"); } [Fact] @@ -420,7 +422,7 @@ internal static void AMethod(){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.ExtensionMethodDiagnosticId, input, 3, 26, "AMethod"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas002, input, 3, 26, "AMethod"); } [Fact] @@ -432,7 +434,7 @@ public static void AMethod(string value){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.ExtensionMethodDiagnosticId, input, 3, 24, "AMethod"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas002, input, 3, 24, "AMethod"); } [Fact] @@ -444,7 +446,7 @@ internal static void AMethod(string value){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.ExtensionMethodDiagnosticId, input, 3, 26, "AMethod"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas002, input, 3, 26, "AMethod"); } [Fact] @@ -456,7 +458,7 @@ internal static void AMethod(this string value){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.ExtensionMethodDiagnosticId, input, 3, 26, "AMethod"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas002, input, 3, 26, "AMethod"); } [Fact] @@ -468,7 +470,7 @@ private static void AMethod(this string value){} } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } [Fact] @@ -480,7 +482,7 @@ public static void AMethod(this string value){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.ExtensionMethodDiagnosticId, input, 3, 24, "AMethod"); + await Verify.DiagnosticExists<MissingDocsAnalyzer>(MissingDocsAnalyzer.Sas002, input, 3, 24, "AMethod"); } [Fact] @@ -496,7 +498,7 @@ private static void AMethod(this string value){} } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists<MissingDocsAnalyzer>(input); } } } \ No newline at end of file diff --git a/src/Tools.Analyzers.Core.UnitTests/Tools.Analyzers.Core.UnitTests.csproj b/src/Tools.Analyzers.Core.UnitTests/Tools.Analyzers.Core.UnitTests.csproj index fcf156cf..b230edd9 100644 --- a/src/Tools.Analyzers.Core.UnitTests/Tools.Analyzers.Core.UnitTests.csproj +++ b/src/Tools.Analyzers.Core.UnitTests/Tools.Analyzers.Core.UnitTests.csproj @@ -15,12 +15,8 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="..\Common\Common.csproj" /> + <ProjectReference Include="..\Tools.Analyzers.Core\Tools.Analyzers.Core.csproj" Aliases="Analyzers" /> <ProjectReference Include="..\UnitTesting.Common\UnitTesting.Common.csproj" /> </ItemGroup> - <ItemGroup> - <ProjectReference Include="..\Tools.Analyzers.Core\Tools.Analyzers.Core.csproj" /> - </ItemGroup> - </Project> diff --git a/src/Tools.Analyzers.Core.UnitTests/Verify.cs b/src/Tools.Analyzers.Core.UnitTests/Verify.cs index cecf0784..31b8f154 100644 --- a/src/Tools.Analyzers.Core.UnitTests/Verify.cs +++ b/src/Tools.Analyzers.Core.UnitTests/Verify.cs @@ -1,21 +1,101 @@ +extern alias Analyzers; +using System.Reflection; +using Analyzers::Infrastructure.WebApi.Common; +using Common.Extensions; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Testing; +using NuGet.Frameworks; namespace Tools.Analyzers.Core.UnitTests; public static class Verify { - public static async Task DiagnosticExists(string diagnosticId, string inputSnippet, int locationX, int locationY, - string argument = "AClass") + private static readonly Assembly[] AdditionalReferences = { - var expected = CSharpAnalyzerVerifier<MissingDocsAnalyzer, DefaultVerifier>.Diagnostic(diagnosticId) - .WithLocation(locationX, locationY) - .WithArguments(argument); - await CSharpAnalyzerVerifier<MissingDocsAnalyzer, DefaultVerifier>.VerifyAnalyzerAsync(inputSnippet, expected); + typeof(Verify).Assembly, + //typeof(Error).Assembly, + typeof(ApiEmptyResult).Assembly + //typeof(IWebApiService).Assembly + }; + + // HACK: we have to define the .NET 7.0 framework here, + // because the current version of Microsoft.CodeAnalysis.Testing.ReferenceAssemblies + // does not contain a value for this framework + private static readonly Lazy<ReferenceAssemblies> LazyNet70 = new(() => + { + if (!NuGetFramework.Parse("net7.0") + .IsPackageBased) + { + // The NuGet version provided at runtime does not recognize the 'net6.0' target framework + throw new NotSupportedException("The 'net6.0' target framework is not supported by this version of NuGet."); + } + + return new ReferenceAssemblies("net7.0", new PackageIdentity("Microsoft.NETCore.App.Ref", "7.0.0"), + Path.Combine("ref", "net7.0")); + }); + + private static ReferenceAssemblies Net70 => LazyNet70.Value; + + public static async Task DiagnosticExists<TAnalyzer>(DiagnosticDescriptor descriptor, string inputSnippet, + int locationX, int locationY, string argument, params object?[]? messageArgs) + where TAnalyzer : DiagnosticAnalyzer, new() + { + await DiagnosticExists<TAnalyzer>(descriptor, inputSnippet, (locationX, locationY, argument), messageArgs); + } + + public static async Task DiagnosticExists<TAnalyzer>(DiagnosticDescriptor descriptor, string inputSnippet, + (int, int, string) expected1, (int, int, string) expected2) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var expectation1 = CSharpAnalyzerVerifier<TAnalyzer, DefaultVerifier>.Diagnostic(descriptor) + .WithLocation(expected1.Item1, expected1.Item2) + .WithArguments(expected1.Item3); + var expectation2 = CSharpAnalyzerVerifier<TAnalyzer, DefaultVerifier>.Diagnostic(descriptor) + .WithLocation(expected2.Item1, expected2.Item2) + .WithArguments(expected2.Item3); + + await RunAnalyzerTest<TAnalyzer>(inputSnippet, new[] { expectation1, expectation2 }); } - public static async Task NoDiagnosticExists(string inputSnippet) + public static async Task NoDiagnosticExists<TAnalyzer>(string inputSnippet) + where TAnalyzer : DiagnosticAnalyzer, new() { - await CSharpAnalyzerVerifier<MissingDocsAnalyzer, DefaultVerifier>.VerifyAnalyzerAsync(inputSnippet); + await RunAnalyzerTest<TAnalyzer>(inputSnippet, null); + } + + private static async Task DiagnosticExists<TAnalyzer>(DiagnosticDescriptor descriptor, string inputSnippet, + (int, int, string) expected1, params object?[]? messageArgs) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var arguments = messageArgs.Exists() && messageArgs!.Any() + ? new object[] { expected1.Item3 }.Concat(messageArgs!) + : new object[] { expected1.Item3 }; + + var expectation = CSharpAnalyzerVerifier<TAnalyzer, DefaultVerifier>.Diagnostic(descriptor) + .WithLocation(expected1.Item1, expected1.Item2) + .WithArguments(arguments.ToArray()!); + + await RunAnalyzerTest<TAnalyzer>(inputSnippet, new[] { expectation }); + } + + private static async Task RunAnalyzerTest<TAnalyzer>(string inputSnippet, DiagnosticResult[]? expected) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var analyzerTest = new CSharpAnalyzerTest<TAnalyzer, DefaultVerifier>(); + foreach (var assembly in AdditionalReferences) + { + analyzerTest.TestState.AdditionalReferences.Add(assembly); + } + + analyzerTest.ReferenceAssemblies = Net70; + analyzerTest.TestCode = inputSnippet; + if (expected is not null && expected.Any()) + { + analyzerTest.ExpectedDiagnostics.AddRange(expected); + } + + await analyzerTest.RunAsync(CancellationToken.None); } } \ No newline at end of file diff --git a/src/Tools.Analyzers.Core.UnitTests/WebApiClassAnalyzerSpec.cs b/src/Tools.Analyzers.Core.UnitTests/WebApiClassAnalyzerSpec.cs new file mode 100644 index 00000000..779c2694 --- /dev/null +++ b/src/Tools.Analyzers.Core.UnitTests/WebApiClassAnalyzerSpec.cs @@ -0,0 +1,779 @@ +extern alias Analyzers; +using Analyzers::Infrastructure.WebApi.Interfaces; +using Analyzers::Tools.Analyzers.Core; +using JetBrains.Annotations; +using Xunit; + +namespace Tools.Analyzers.Core.UnitTests; + +extern alias Analyzers; + +[UsedImplicitly] +public class WebApiClassAnalyzerSpec +{ + [Trait("Category", "Unit")] + public class GivenAnyRule + { + [Fact] + public async Task WhenInExcludedNamespace_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Interfaces; +namespace Common; +public class AClass : IWebApiService +{ +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenNotWebApiClass_ThenNoAlert() + { + const string input = @" +namespace ANamespace; +public class AClass +{ +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenHasNoMethods_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Interfaces; +namespace ANamespace; +public class AClass : IWebApiService +{ +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenHasPrivateMethod_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Interfaces; +namespace ANamespace; +public class AClass : IWebApiService +{ + private void AMethod(){} +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenHasInternalMethod_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Interfaces; +namespace ANamespace; +public class AClass : IWebApiService +{ + internal void AMethod(){} +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + } + + [Trait("Category", "Unit")] + public class GivenRuleSas010 + { + [Fact] + public async Task WhenHasPublicMethodWithVoidReturnType_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Interfaces; +namespace ANamespace; +public class AClass : IWebApiService +{ + public void AMethod(){} +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas010, input, 6, 17, "AMethod"); + } + + [Fact] + public async Task WhenHasPublicMethodWithTaskReturnType_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +namespace ANamespace; +public class AClass : IWebApiService +{ + public Task AMethod(){ return Task.CompletedTask; } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas010, input, 7, 17, "AMethod"); + } + + [Fact] + public async Task WhenHasPublicMethodWithWrongTaskReturnType_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +namespace ANamespace; +public class AClass : IWebApiService +{ + public Task<string> AMethod(){ return Task.FromResult(""""); } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas010, input, 7, 25, "AMethod"); + } + + [Fact] + public async Task WhenHasPublicMethodWithTaskOfApiEmptyResultReturnType_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public Task<ApiEmptyResult> AMethod(TestRequest1 request) + { + return Task.FromResult<ApiEmptyResult>(() => new Result<EmptyResponse, Error>()); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenHasPublicMethodWithTaskOfApiResultReturnType_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public Task<ApiResult<TestResource, TestResponse>> AMethod(TestRequest1 request) + { + return Task.FromResult<ApiResult<TestResource, TestResponse>>(() => new Result<TestResponse, Error>()); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenHasPublicMethodWithTaskOfApiPostResultReturnType_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public Task<ApiPostResult<TestResource, TestResponse>> AMethod(TestRequest1 request) + { + return Task.FromResult<ApiPostResult<TestResource, TestResponse>>(() => new Result<PostResult<TestResponse>, Error>()); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenHasPublicMethodWithWrongNakedReturnType_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Interfaces; +namespace ANamespace; +public class AClass : IWebApiService +{ + public string AMethod(){ return """"; } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas010, input, 6, 19, "AMethod"); + } + + [Fact] + public async Task WhenHasPublicMethodWithNakedApiEmptyResultReturnType_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenHasPublicMethodWithNakedApiResultReturnType_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiResult<TestResource, TestResponse> AMethod(TestRequest1 request) + { + return () => new Result<TestResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenHasPublicMethodWithNakedApiPostResultReturnType_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiPostResult<TestResource, TestResponse> AMethod(TestRequest1 request) + { + return () => new Result<PostResult<TestResponse>, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + } + + [Trait("Category", "Unit")] + public class GivenRuleSas011AndSas012 + { + [Fact] + public async Task WhenHasNoParameters_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +namespace ANamespace; +public class AClass : IWebApiService +{ + public ApiEmptyResult AMethod() + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas011, input, 9, 27, "AMethod"); + } + + [Fact] + public async Task WhenHasTooManyParameters_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + public ApiEmptyResult AMethod(TestRequest1 request, CancellationToken cancellationToken, string value) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas011, input, 11, 27, "AMethod"); + } + + [Fact] + public async Task WhenFirstParameterIsNotRequestType_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +namespace ANamespace; +public class AClass : IWebApiService +{ + public ApiEmptyResult AMethod(string value) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas011, input, 9, 27, "AMethod"); + } + + [Fact] + public async Task WhenSecondParameterIsNotCancellationToken_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + public ApiEmptyResult AMethod(TestRequest1 request, string value) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas012, input, 10, 27, "AMethod"); + } + + [Fact] + public async Task WhenOnlyRequest_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenRequestAndCancellation_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod(TestRequest1 request, CancellationToken cancellationToken) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + } + + [Trait("Category", "Unit")] + public class GivenRuleSas013 + { + [Fact] + public async Task WhenHasNoAttributes_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + public ApiEmptyResult AMethod(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas013, input, 10, 27, "AMethod"); + } + + [Fact] + public async Task WhenMissingAttribute_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [TestAttribute] + public ApiEmptyResult AMethod(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas013, input, 11, 27, "AMethod"); + } + + [Fact] + public async Task WhenAttribute_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + } + + [Trait("Category", "Unit")] + public class GivenRuleSas014 + { + [Fact] + public async Task WhenOneRoute_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod1(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenTwoWithSameRoute_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod1(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod2(TestRequest2 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenThreeWithSameRouteFirstSegment_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource/1"", WebApiOperation.Get)] + public ApiEmptyResult AMethod1(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } + [WebApiRoute(""/aresource/2"", WebApiOperation.Get)] + public ApiEmptyResult AMethod2(TestRequest2 request) + { + return () => new Result<EmptyResponse, Error>(); + } + [WebApiRoute(""/aresource/3"", WebApiOperation.Get)] + public ApiEmptyResult AMethod3(TestRequest3 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenDifferentRouteSegments_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource/1"", WebApiOperation.Get)] + public ApiEmptyResult AMethod1(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } + [WebApiRoute(""/aresource/2"", WebApiOperation.Get)] + public ApiEmptyResult AMethod2(TestRequest2 request) + { + return () => new Result<EmptyResponse, Error>(); + } + [WebApiRoute(""/anotherresource/1"", WebApiOperation.Get)] + public ApiEmptyResult AMethod3(TestRequest3 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas014, input, 21, 27, "AMethod3"); + } + } + + [Trait("Category", "Unit")] + public class GivenRuleSas015 + { + [Fact] + public async Task WhenNoDuplicateRequests_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenDuplicateRequests_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod1(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod2(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod3(TestRequest2 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas015, input, (11, 27, "AMethod1"), + (16, 27, "AMethod2")); + } + } + + [Trait("Category", "Unit")] + public class GivenRuleSas016 + { + [Fact] + public async Task WhenDeleteAndApiEmptyResult_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Delete)] + public ApiEmptyResult AMethod(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenGetAndApiEmptyResult_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenPostAndApiPostResult_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Post)] + public ApiPostResult<TestResource, TestResponse> AMethod(TestRequest1 request) + { + return () => new PostResult<TestResponse>(new TestResponse(), ""/alocation""); + } +}"; + + await Verify.NoDiagnosticExists<WebApiClassAnalyzer>(input); + } + + [Fact] + public async Task WhenPostAndOtherReturnResult_ThenAlerts() + { + const string input = @" +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using System.Threading.Tasks; +using Common; +using Tools.Analyzers.Core.UnitTests; +namespace ANamespace; +public class AClass : IWebApiService +{ + [WebApiRoute(""/aresource"", WebApiOperation.Post)] + public ApiEmptyResult AMethod1(TestRequest1 request) + { + return () => new Result<EmptyResponse, Error>(); + } +}"; + + await Verify.DiagnosticExists<WebApiClassAnalyzer>(WebApiClassAnalyzer.Sas016, input, 11, 27, "AMethod1", + WebApiOperation.Post, "ApiPostResult<TResource, TResponse>"); + } + } +} + +[UsedImplicitly] +public class TestResource +{ +} + +[UsedImplicitly] +public class TestResponse : IWebResponse +{ +} + +[UsedImplicitly] +public class TestRequest1 : IWebRequest<TestResponse> +{ +} + +[UsedImplicitly] +public class TestRequest2 : IWebRequest<TestResponse> +{ +} + +[UsedImplicitly] +public class TestRequest3 : IWebRequest<TestResponse> +{ +} + +[AttributeUsage(AttributeTargets.Method)] +[UsedImplicitly] +public class TestAttribute : Attribute +{ +} \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/AnalyzerConstants.cs b/src/Tools.Analyzers.Core/AnalyzerConstants.cs new file mode 100644 index 00000000..943bf824 --- /dev/null +++ b/src/Tools.Analyzers.Core/AnalyzerConstants.cs @@ -0,0 +1,22 @@ +namespace Tools.Analyzers.Core; + +public static class AnalyzerConstants +{ + public static readonly string[] CommonNamespaces = + { +#if TESTINGONLY + "<global namespace>", +#endif + "Common", "UnitTesting.Common", "IntegrationTesting.Common", + "Infrastructure.Common", "Infrastructure.Interfaces", + "Infrastructure.Persistence.Common", "Infrastructure.Persistence.Interfaces", + "Infrastructure.WebApi.Common", "Infrastructure.WebApi.Interfaces", + "Domain.Common", "Domain.Interfaces", "Application.Common", "Application.Interfaces" + }; + + public static class Categories + { + public const string Documentation = "SaaStackDocumentation"; + public const string WebApi = "SaaStackWebApi"; + } +} \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/AnalyzerReleases.Shipped.md b/src/Tools.Analyzers.Core/AnalyzerReleases.Shipped.md index c4493a49..46cac970 100644 --- a/src/Tools.Analyzers.Core/AnalyzerReleases.Shipped.md +++ b/src/Tools.Analyzers.Core/AnalyzerReleases.Shipped.md @@ -2,7 +2,14 @@ ### New Rules - Rule ID | Category | Severity | Notes ----------|---------------|----------|----------------------------------------------- - SAS001 | Documentation | Warning | The class must have documentation. - SAS002 | Documentation | Warning | The extension method must have documentation. \ No newline at end of file + Rule ID | Category | Severity | Notes +---------|-----------------------|----------|---------------------------------------------------------- + SAS001 | SaaStackDocumentation | Warning | The class must have documentation. + SAS002 | SaaStackDocumentation | Warning | The extension method must have documentation. + SAS010 | SaaStackWebApi | Warning | The service operation returns wrong type. + SAS011 | SaaStackWebApi | Warning | The service operation first param is invalid. + SAS012 | SaaStackWebApi | Warning | The service operation second param is invalid. + SAS013 | SaaStackWebApi | Warning | The service operation missing attribute. + SAS014 | SaaStackWebApi | Warning | A service operation has wrong primary resource. + SAS015 | SaaStackWebApi | Warning | A service operation has duplicate request type. + SAS016 | SaaStackWebApi | Warning | A service operation should return an appropriate result. \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/Extensions/DiagnosticExtensions.cs b/src/Tools.Analyzers.Core/Extensions/DiagnosticExtensions.cs new file mode 100644 index 00000000..90323e38 --- /dev/null +++ b/src/Tools.Analyzers.Core/Extensions/DiagnosticExtensions.cs @@ -0,0 +1,53 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Tools.Analyzers.Core.Extensions; + +public static class DiagnosticExtensions +{ + public static DiagnosticDescriptor GetDescriptor(this string diagnosticId, DiagnosticSeverity severity, + string category, string title, string description, string messageFormat) + { + return new DiagnosticDescriptor(diagnosticId, title.GetLocalizableString(), + messageFormat.GetLocalizableString(), category, DiagnosticSeverity.Warning, true, + description.GetLocalizableString()); + } + + public static void ReportDiagnostic(this SyntaxNodeAnalysisContext context, DiagnosticDescriptor descriptor, + MethodDeclarationSyntax methodDeclarationSyntax, params object?[]? messageArgs) + { + var identifier = methodDeclarationSyntax.Identifier; + var arguments = messageArgs is not null && messageArgs.Any() + ? new object[] { identifier.Text }.Concat(messageArgs) + : new object[] { identifier.Text }; + var diagnostic = Diagnostic.Create(descriptor, identifier.GetLocation(), arguments.ToArray()); + context.ReportDiagnostic(diagnostic); + } + + public static void ReportDiagnostic(this SyntaxNodeAnalysisContext context, DiagnosticDescriptor descriptor, + MemberDeclarationSyntax memberDeclarationSyntax, params object?[]? messageArgs) + { + var location = Location.None; + var text = "Unknown"; + + if (memberDeclarationSyntax is BaseTypeDeclarationSyntax baseType) + { + location = baseType.Identifier.GetLocation(); + text = baseType.Identifier.Text; + } + + if (memberDeclarationSyntax is DelegateDeclarationSyntax delegateType) + { + location = delegateType.Identifier.GetLocation(); + text = delegateType.Identifier.Text; + } + + var arguments = messageArgs is not null && messageArgs.Any() + ? new object[] { text }.Concat(messageArgs) + : new object[] { text }; + + var diagnostic = Diagnostic.Create(descriptor, location, text, arguments.ToArray()); + context.ReportDiagnostic(diagnostic); + } +} \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/Extensions/ObjectExtensions.cs b/src/Tools.Analyzers.Core/Extensions/ObjectExtensions.cs new file mode 100644 index 00000000..d5353652 --- /dev/null +++ b/src/Tools.Analyzers.Core/Extensions/ObjectExtensions.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; + +// ReSharper disable once CheckNamespace +namespace Common.Extensions; + +public static class ObjectExtensions +{ + /// <summary> + /// Whether the object does not exist + /// </summary> + [ContractAnnotation("null => true; notnull => false")] + public static bool NotExists(this object? instance) + { + return instance is null; + } +} \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/Extensions/ResourceExtensions.cs b/src/Tools.Analyzers.Core/Extensions/ResourceExtensions.cs new file mode 100644 index 00000000..a8de0ef1 --- /dev/null +++ b/src/Tools.Analyzers.Core/Extensions/ResourceExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.CodeAnalysis; + +namespace Tools.Analyzers.Core.Extensions; + +public static class ResourceExtensions +{ + public static LocalizableResourceString GetLocalizableString(this string name) + { + return new LocalizableResourceString(name, Resources.ResourceManager, typeof(Resources)); + } +} \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/Extensions/StringExtensions.cs b/src/Tools.Analyzers.Core/Extensions/StringExtensions.cs new file mode 100644 index 00000000..0d650b58 --- /dev/null +++ b/src/Tools.Analyzers.Core/Extensions/StringExtensions.cs @@ -0,0 +1,33 @@ +using JetBrains.Annotations; + +// ReSharper disable once CheckNamespace +namespace Common.Extensions; + +public static class StringExtensions +{ + /// <summary> + /// Whether the string value contains no value: it is either: null, empty or only whitespaces + /// </summary> + [ContractAnnotation("null => true; notnull => false")] + public static bool HasNoValue(this string? value) + { + return string.IsNullOrEmpty(value) || string.IsNullOrWhiteSpace(value); + } + + /// <summary> + /// Whether the string value contains any value except: null, empty or only whitespaces + /// </summary> + [ContractAnnotation("null => false; notnull => true")] + public static bool HasValue(this string? value) + { + return !string.IsNullOrEmpty(value) && !string.IsNullOrWhiteSpace(value); + } + + /// <summary> + /// Formats the <see cref="value" /> with the <see cref="arguments" /> + /// </summary> + public static string Format(this string value, params object[] arguments) + { + return string.Format(value, arguments); + } +} \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/Extensions/SyntaxExtensions.cs b/src/Tools.Analyzers.Core/Extensions/SyntaxExtensions.cs new file mode 100644 index 00000000..c04b4e87 --- /dev/null +++ b/src/Tools.Analyzers.Core/Extensions/SyntaxExtensions.cs @@ -0,0 +1,50 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Tools.Analyzers.Core.Extensions; + +internal static class SyntaxExtensions +{ + public static DocumentationCommentTriviaSyntax? GetDocumentationCommentTriviaSyntax(this SyntaxNode node) + { + foreach (var leadingTrivia in node.GetLeadingTrivia()) + { + if (leadingTrivia.GetStructure() is DocumentationCommentTriviaSyntax structure) + { + return structure; + } + } + + return null; + } + + public static XmlNodeSyntax? GetFirstXmlElement(this SyntaxList<XmlNodeSyntax> content, string elementName) + { + return content.GetXmlElements(elementName) + .FirstOrDefault(); + } + + private static IEnumerable<XmlNodeSyntax> GetXmlElements(this SyntaxList<XmlNodeSyntax> content, string elementName) + { + foreach (var syntax in content) + { + if (syntax is XmlEmptyElementSyntax emptyElement) + { + if (string.Equals(elementName, emptyElement.Name.ToString(), StringComparison.Ordinal)) + { + yield return emptyElement; + } + + continue; + } + + if (syntax is XmlElementSyntax elementSyntax) + { + if (string.Equals(elementName, elementSyntax.StartTag?.Name?.ToString(), StringComparison.Ordinal)) + { + yield return elementSyntax; + } + } + } + } +} \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/Extensions/SyntaxFilterExtensions.cs b/src/Tools.Analyzers.Core/Extensions/SyntaxFilterExtensions.cs new file mode 100644 index 00000000..99fa4714 --- /dev/null +++ b/src/Tools.Analyzers.Core/Extensions/SyntaxFilterExtensions.cs @@ -0,0 +1,317 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Tools.Analyzers.Core.Extensions; + +internal static class SyntaxFilterExtensions +{ + public static AttributeData? GetAttributeOfType<TAttribute>(this MethodDeclarationSyntax methodDeclarationSyntax, + SyntaxNodeAnalysisContext context) + { + var symbol = context.Compilation.GetSemanticModel(methodDeclarationSyntax.SyntaxTree) + .GetDeclaredSymbol(methodDeclarationSyntax); + if (symbol is null) + { + return null; + } + + var attributeType = context.Compilation.GetTypeByMetadataName(typeof(TAttribute).FullName!)!; + var attributes = symbol.GetAttributes(); + + return attributes.FirstOrDefault(attr => + SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attributeType)); + } + + public static ITypeSymbol? GetBaseOfType<TType>(this ParameterSyntax parameterSyntax, + SyntaxNodeAnalysisContext context) + { + var symbol = context.Compilation.GetSemanticModel(parameterSyntax.SyntaxTree) + .GetDeclaredSymbol(parameterSyntax); + if (symbol is null) + { + return null; + } + + var parameterType = context.Compilation.GetTypeByMetadataName(typeof(TType).FullName!)!; + + var isOfType = SymbolEqualityComparer.Default.Equals(symbol.Type.OriginalDefinition, parameterType); + if (isOfType) + { + return null; + } + + var isDerivedFrom = symbol.Type.AllInterfaces.Any(@interface => + SymbolEqualityComparer.Default.Equals(@interface.OriginalDefinition, parameterType)); + if (isDerivedFrom) + { + return symbol.Type; + } + + return null; + } + + public static bool IsEmptyNode(this XmlNodeSyntax nodeSyntax) + { + if (nodeSyntax is XmlTextSyntax textSyntax) + { + return textSyntax.TextTokens.All(token => string.IsNullOrWhiteSpace(token.ToString())); + } + + if (nodeSyntax is XmlElementSyntax xmlElementSyntax) + { + var content = xmlElementSyntax.Content; + return content.All(IsEmptyNode); + } + + return true; + } + + public static bool IsExcludedInNamespace(this SyntaxNodeAnalysisContext context, string[] excludedNamespaces) + { + var parentContext = context.ContainingSymbol; + if (parentContext is null) + { + return true; + } + + var containingNamespace = parentContext.ContainingNamespace.ToDisplayString(); + var excluded = excludedNamespaces.Contains(containingNamespace); + + return excluded; + } + + public static bool IsIncludedInNamespace(this SyntaxNodeAnalysisContext context, string[] includedNamespaces) + { + var parentContext = context.ContainingSymbol; + if (parentContext is null) + { + return true; + } + + var containingNamespace = parentContext.ContainingNamespace.ToDisplayString(); + var included = includedNamespaces.Contains(containingNamespace); + + return included; + } + + public static bool IsLanguageForCSharp(this SyntaxNode docs) + { + return docs.Language == "C#"; + } + + public static bool IsNestedAndNotPublicType(this MemberDeclarationSyntax memberDeclaration) + { + var isNested = memberDeclaration.Parent.IsKind(SyntaxKind.ClassDeclaration); + if (!isNested) + { + return false; + } + + var accessibility = new Accessibility(memberDeclaration.Modifiers); + if (accessibility.IsPublic) + { + return false; + } + + return true; + } + + public static bool IsNotPublicInstanceMethod(this MethodDeclarationSyntax methodDeclarationSyntax) + { + var accessibility = new Accessibility(methodDeclarationSyntax.Modifiers); + return accessibility is { IsPublic: false }; + } + + public static bool IsNotPublicNorInternalInstanceType(this MemberDeclarationSyntax memberDeclaration) + { + var accessibility = new Accessibility(memberDeclaration.Modifiers); + if (accessibility is { IsPublic: false, IsInternal: false }) + { + return true; + } + + if (accessibility.IsStatic) + { + return true; + } + + return false; + } + + public static bool IsNotPublicOrInternalStaticMethod(this MethodDeclarationSyntax methodDeclarationSyntax) + { + var accessibility = new Accessibility(methodDeclarationSyntax.Modifiers); + if (accessibility is { IsPublic: false, IsInternal: false }) + { + return true; + } + + if (!accessibility.IsStatic) + { + return true; + } + + return false; + } + + public static bool IsNotType<TParent>(this ClassDeclarationSyntax classDeclarationSyntax, + SyntaxNodeAnalysisContext context) + { + var symbol = context.Compilation.GetSemanticModel(classDeclarationSyntax.SyntaxTree) + .GetDeclaredSymbol(classDeclarationSyntax); + if (symbol is null) + { + return false; + } + + var parentType = context.Compilation.GetTypeByMetadataName(typeof(TParent).FullName!)!; + + var isOfType = symbol.AllInterfaces.Any(@interface => + SymbolEqualityComparer.Default.Equals(@interface.OriginalDefinition, parentType)); + + return !isOfType; + } + + public static bool IsNotType<TType>(this ParameterSyntax parameterSyntax, SyntaxNodeAnalysisContext context) + { + var symbol = context.Compilation.GetSemanticModel(parameterSyntax.SyntaxTree) + .GetDeclaredSymbol(parameterSyntax); + if (symbol is null) + { + return false; + } + + var parameterType = context.Compilation.GetTypeByMetadataName(typeof(TType).FullName!)!; + + var isOfType = SymbolEqualityComparer.Default.Equals(symbol.Type.OriginalDefinition, parameterType); + if (isOfType) + { + return false; + } + + var isDerivedFrom = symbol.Type.AllInterfaces.Any(@interface => + SymbolEqualityComparer.Default.Equals(@interface.OriginalDefinition, parameterType)); + + return !isDerivedFrom; + } + + public static bool IsParentTypeNotPublic(this MemberDeclarationSyntax memberDeclaration) + { + var parent = memberDeclaration.Parent; + if (parent is not BaseTypeDeclarationSyntax typeDeclaration) + { + return false; + } + + var accessibility = new Accessibility(typeDeclaration.Modifiers); + if (accessibility.IsPublic) + { + return false; + } + + return true; + } + + // ReSharper disable once UnusedMember.Local + public static bool IsPublicOrInternalExtensionMethod(this MethodDeclarationSyntax methodDeclarationSyntax) + { + var isNotPublicOrInternal = IsNotPublicOrInternalStaticMethod(methodDeclarationSyntax); + if (isNotPublicOrInternal) + { + return false; + } + + var firstParameter = methodDeclarationSyntax.ParameterList.Parameters.FirstOrDefault(); + if (firstParameter is null) + { + return false; + } + + var isExtension = firstParameter.Modifiers.Any(mod => mod.IsKind(SyntaxKind.ThisKeyword)); + if (!isExtension) + { + return false; + } + + return true; + } + + public static bool IsReturnTypeNotMatching(this MemberDeclarationSyntax memberDeclaration, + SyntaxNodeAnalysisContext context, out ITypeSymbol? returnType, params Type[] allowedTypes) + { + returnType = null; + var symbol = context.Compilation.GetSemanticModel(memberDeclaration.SyntaxTree) + .GetDeclaredSymbol(memberDeclaration); + if (symbol is null) + { + return true; + } + + if (symbol is not IMethodSymbol methodSymbol) + { + return true; + } + + returnType = methodSymbol.ReturnType; + + var voidSymbol = context.Compilation.GetTypeByMetadataName(typeof(void).FullName!)!; + if (SymbolEqualityComparer.Default.Equals(returnType.OriginalDefinition, voidSymbol)) + { + return true; + } + + var taskSymbol = context.Compilation.GetTypeByMetadataName(typeof(Task).FullName!)!; + if (SymbolEqualityComparer.Default.Equals(returnType.OriginalDefinition, taskSymbol)) + { + return true; + } + + var genericTaskSymbol = context.Compilation.GetTypeByMetadataName(typeof(Task<>).FullName!)!; + if (SymbolEqualityComparer.Default.Equals(returnType.OriginalDefinition, genericTaskSymbol)) + { + //Task<T> + if (returnType is not INamedTypeSymbol namedTypeSymbol) + { + return true; + } + + var genericType = namedTypeSymbol.TypeArguments.First(); + return NotMatches(genericType); + } + + //Naked type + return NotMatches(returnType); + + bool NotMatches(ITypeSymbol type) + { + foreach (var allowedType in allowedTypes) + { + var allowedSymbol = context.Compilation.GetTypeByMetadataName(allowedType.FullName!)!; + if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, allowedSymbol)) + { + return false; + } + } + + return true; + } + } +} + +public class Accessibility +{ + public Accessibility(SyntaxTokenList modifiers) + { + IsPublic = modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword)); + IsInternal = modifiers.Any(mod => mod.IsKind(SyntaxKind.InternalKeyword)); + IsStatic = modifiers.Any(mod => mod.IsKind(SyntaxKind.StaticKeyword)); + } + + public bool IsInternal { get; } + + public bool IsPublic { get; } + + public bool IsStatic { get; } +} \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/IWebRequest.cs b/src/Tools.Analyzers.Core/IWebRequest.cs new file mode 100644 index 00000000..25dcca75 --- /dev/null +++ b/src/Tools.Analyzers.Core/IWebRequest.cs @@ -0,0 +1,19 @@ +// ReSharper disable once CheckNamespace + +namespace Infrastructure.WebApi.Interfaces; + +/// <summary> +/// Defines a incoming REST request and response. +/// </summary> +public interface IWebRequest +{ +} + +/// <summary> +/// Defines a incoming REST request and response. +/// </summary> +// ReSharper disable once UnusedTypeParameter +public interface IWebRequest<TResponse> : IWebRequest + where TResponse : IWebResponse +{ +} \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/MissingDocsAnalyzer.cs b/src/Tools.Analyzers.Core/MissingDocsAnalyzer.cs index d88b8633..105769ce 100644 --- a/src/Tools.Analyzers.Core/MissingDocsAnalyzer.cs +++ b/src/Tools.Analyzers.Core/MissingDocsAnalyzer.cs @@ -3,45 +3,31 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; +using Tools.Analyzers.Core.Extensions; namespace Tools.Analyzers.Core; /// <summary> /// An analyzer to find public declarations that are missing a documentation <summary> node. +/// SAS001: All public/internal classes, structs, records, interfaces, delegates and enums +/// SAS002: All public/internal static methods and all public/internal extension methods (in public types) /// Document declarations are only enforced for Core common/interfaces projects. -/// All public/internal classes, structs, records, interfaces, delegates and enums -/// All public/internal static methods and all public/internal extension methods (in public types) /// </summary> [DiagnosticAnalyzer(LanguageNames.CSharp)] public class MissingDocsAnalyzer : DiagnosticAnalyzer { - public const string ExtensionMethodDiagnosticId = "SAS002"; - private const string RoslynCategory = "Documentation"; private const string SummaryXmlElementName = "summary"; - public const string TypeDiagnosticId = "SAS001"; - private static readonly string[] IncludedNamespaces = - { -#if TESTINGONLY - "<global namespace>", -#endif - "Common", "UnitTesting.Common", "IntegrationTesting.Common", - "Infrastructure.Common", "Infrastructure.Interfaces", - "Infrastructure.Persistence.Common", "Infrastructure.Persistence.Interfaces", - "Infrastructure.WebApi.Common", "Infrastructure.WebApi.Interfaces", - "Domain.Common", "Domain.Interfaces", "Application.Common", "Application.Interfaces" - }; - - private static readonly DiagnosticDescriptor TypeRule = new(TypeDiagnosticId, - GetResource(nameof(Resources.SAS001Title)), GetResource(nameof(Resources.SAS001MessageFormat)), RoslynCategory, - DiagnosticSeverity.Warning, true, GetResource(nameof(Resources.SAS001Description))); + internal static readonly DiagnosticDescriptor Sas001 = "SAS001".GetDescriptor(DiagnosticSeverity.Warning, + AnalyzerConstants.Categories.Documentation, nameof(Resources.SAS001Title), nameof(Resources.SAS001Description), + nameof(Resources.SAS001MessageFormat)); - private static readonly DiagnosticDescriptor ExtensionMethodRule = new(ExtensionMethodDiagnosticId, - GetResource(nameof(Resources.SAS002Title)), GetResource(nameof(Resources.SAS002MessageFormat)), RoslynCategory, - DiagnosticSeverity.Warning, true, GetResource(nameof(Resources.SAS002Description))); + internal static readonly DiagnosticDescriptor Sas002 = "SAS002".GetDescriptor(DiagnosticSeverity.Warning, + AnalyzerConstants.Categories.Documentation, nameof(Resources.SAS002Title), nameof(Resources.SAS002Description), + nameof(Resources.SAS002MessageFormat)); public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => - ImmutableArray.Create(TypeRule, ExtensionMethodRule); + ImmutableArray.Create(Sas001, Sas002); public override void Initialize(AnalysisContext context) { @@ -61,17 +47,17 @@ private static void AnalyzeType(SyntaxNodeAnalysisContext context) return; } - if (IsIgnoredNamespace(context)) + if (!context.IsIncludedInNamespace(AnalyzerConstants.CommonNamespaces)) { return; } - if (IsNotPublicNorInternalInstanceType(typeDeclarationSyntax)) + if (typeDeclarationSyntax.IsNotPublicNorInternalInstanceType()) { return; } - if (IsNestedAndNotPublicType(typeDeclarationSyntax)) + if (typeDeclarationSyntax.IsNestedAndNotPublicType()) { return; } @@ -79,13 +65,13 @@ private static void AnalyzeType(SyntaxNodeAnalysisContext context) var docs = typeDeclarationSyntax.GetDocumentationCommentTriviaSyntax(); if (docs is null) { - ReportDiagnostic(context, typeDeclarationSyntax); + context.ReportDiagnostic(Sas001, typeDeclarationSyntax); return; } - if (!IsXmlDocsForCSharp(docs)) + if (!docs.IsLanguageForCSharp()) { - ReportDiagnostic(context, typeDeclarationSyntax); + context.ReportDiagnostic(Sas001, typeDeclarationSyntax); return; } @@ -93,13 +79,13 @@ private static void AnalyzeType(SyntaxNodeAnalysisContext context) var summary = xmlContent.GetFirstXmlElement(SummaryXmlElementName); if (summary is null) { - ReportDiagnostic(context, typeDeclarationSyntax); + context.ReportDiagnostic(Sas001, typeDeclarationSyntax); return; } - if (IsEmptyNode(summary)) + if (summary.IsEmptyNode()) { - ReportDiagnostic(context, typeDeclarationSyntax); + context.ReportDiagnostic(Sas001, typeDeclarationSyntax); } } @@ -111,17 +97,17 @@ private static void AnalyzeMethod(SyntaxNodeAnalysisContext context) return; } - if (IsIgnoredNamespace(context)) + if (!context.IsIncludedInNamespace(AnalyzerConstants.CommonNamespaces)) { return; } - if (IsParentTypeNotPublic(methodDeclarationSyntax)) + if (methodDeclarationSyntax.IsParentTypeNotPublic()) { return; } - if (IsNotPublicOrInternalStaticMethod(methodDeclarationSyntax)) + if (methodDeclarationSyntax.IsNotPublicOrInternalStaticMethod()) { return; } @@ -129,13 +115,13 @@ private static void AnalyzeMethod(SyntaxNodeAnalysisContext context) var docs = methodDeclarationSyntax.GetDocumentationCommentTriviaSyntax(); if (docs is null) { - ReportDiagnostic(context, methodDeclarationSyntax); + context.ReportDiagnostic(Sas002, methodDeclarationSyntax); return; } - if (!IsXmlDocsForCSharp(docs)) + if (!docs.IsLanguageForCSharp()) { - ReportDiagnostic(context, methodDeclarationSyntax); + context.ReportDiagnostic(Sas002, methodDeclarationSyntax); return; } @@ -143,235 +129,13 @@ private static void AnalyzeMethod(SyntaxNodeAnalysisContext context) var summary = xmlContent.GetFirstXmlElement(SummaryXmlElementName); if (summary is null) { - ReportDiagnostic(context, methodDeclarationSyntax); + context.ReportDiagnostic(Sas002, methodDeclarationSyntax); return; } - if (IsEmptyNode(summary)) - { - ReportDiagnostic(context, methodDeclarationSyntax); - } - } - - private static LocalizableResourceString GetResource(string name) - { - return new LocalizableResourceString(name, Resources.ResourceManager, typeof(Resources)); - } - - private static void ReportDiagnostic(SyntaxNodeAnalysisContext context, - MemberDeclarationSyntax memberDeclarationSyntax) - { - var location = Location.None; - var text = "Unknown"; - - if (memberDeclarationSyntax is BaseTypeDeclarationSyntax baseType) - { - location = baseType.Identifier.GetLocation(); - text = baseType.Identifier.Text; - } - - if (memberDeclarationSyntax is DelegateDeclarationSyntax delegateType) + if (summary.IsEmptyNode()) { - location = delegateType.Identifier.GetLocation(); - text = delegateType.Identifier.Text; + context.ReportDiagnostic(Sas002, methodDeclarationSyntax); } - - var diagnostic = Diagnostic.Create(TypeRule, location, text); - context.ReportDiagnostic(diagnostic); - } - - private static void ReportDiagnostic(SyntaxNodeAnalysisContext context, - MethodDeclarationSyntax methodDeclarationSyntax) - { - var identifier = methodDeclarationSyntax.Identifier; - var diagnostic = Diagnostic.Create(ExtensionMethodRule, identifier.GetLocation(), identifier.Text); - context.ReportDiagnostic(diagnostic); - } - - private static bool IsEmptyNode(XmlNodeSyntax nodeSyntax) - { - if (nodeSyntax is XmlTextSyntax textSyntax) - { - return textSyntax.TextTokens.All(token => string.IsNullOrWhiteSpace(token.ToString())); - } - - if (nodeSyntax is XmlElementSyntax xmlElementSyntax) - { - var content = xmlElementSyntax.Content; - return content.All(IsEmptyNode); - } - - return true; } - - private static bool IsIgnoredNamespace(SyntaxNodeAnalysisContext context) - { - var parentContext = context.ContainingSymbol; - if (parentContext is null) - { - return true; - } - - var containingNamespace = parentContext.ContainingNamespace.ToDisplayString(); - var included = IncludedNamespaces.Contains(containingNamespace); - - return !included; - } - - private static bool IsNotPublicNorInternalInstanceType(MemberDeclarationSyntax memberDeclaration) - { - var accessibility = new Accessibility(memberDeclaration.Modifiers); - if (accessibility is { IsPublic: false, IsInternal: false }) - { - return true; - } - - if (accessibility.IsStatic) - { - return true; - } - - return false; - } - - private static bool IsNestedAndNotPublicType(MemberDeclarationSyntax memberDeclaration) - { - var isNested = memberDeclaration.Parent.IsKind(SyntaxKind.ClassDeclaration); - if (!isNested) - { - return false; - } - - var accessibility = new Accessibility(memberDeclaration.Modifiers); - if (accessibility.IsPublic) - { - return false; - } - - return true; - } - - private static bool IsParentTypeNotPublic(MemberDeclarationSyntax memberDeclaration) - { - var parent = memberDeclaration.Parent; - if (parent is not BaseTypeDeclarationSyntax typeDeclaration) - { - return false; - } - - var accessibility = new Accessibility(typeDeclaration.Modifiers); - if (accessibility.IsPublic) - { - return false; - } - - return true; - } - - private static bool IsNotPublicOrInternalStaticMethod(MethodDeclarationSyntax methodDeclarationSyntax) - { - var accessibility = new Accessibility(methodDeclarationSyntax.Modifiers); - if (accessibility is { IsPublic: false, IsInternal: false }) - { - return true; - } - - if (!accessibility.IsStatic) - { - return true; - } - - return false; - } - - // ReSharper disable once UnusedMember.Local - private static bool IsPublicOrInternalExtensionMethod(MethodDeclarationSyntax methodDeclarationSyntax) - { - var isNotPublicOrInternal = IsNotPublicOrInternalStaticMethod(methodDeclarationSyntax); - if (isNotPublicOrInternal) - { - return false; - } - - var firstParameter = methodDeclarationSyntax.ParameterList.Parameters.FirstOrDefault(); - if (firstParameter is null) - { - return false; - } - - var isExtension = firstParameter.Modifiers.Any(mod => mod.IsKind(SyntaxKind.ThisKeyword)); - if (!isExtension) - { - return false; - } - - return true; - } - - private static bool IsXmlDocsForCSharp(SyntaxNode docs) - { - return docs.Language == "C#"; - } -} - -internal static class SyntaxExtensions -{ - public static DocumentationCommentTriviaSyntax? GetDocumentationCommentTriviaSyntax(this SyntaxNode node) - { - foreach (var leadingTrivia in node.GetLeadingTrivia()) - { - if (leadingTrivia.GetStructure() is DocumentationCommentTriviaSyntax structure) - { - return structure; - } - } - - return null; - } - - public static XmlNodeSyntax? GetFirstXmlElement(this SyntaxList<XmlNodeSyntax> content, string elementName) - { - return content.GetXmlElements(elementName) - .FirstOrDefault(); - } - - private static IEnumerable<XmlNodeSyntax> GetXmlElements(this SyntaxList<XmlNodeSyntax> content, string elementName) - { - foreach (var syntax in content) - { - if (syntax is XmlEmptyElementSyntax emptyElement) - { - if (string.Equals(elementName, emptyElement.Name.ToString(), StringComparison.Ordinal)) - { - yield return emptyElement; - } - - continue; - } - - if (syntax is XmlElementSyntax elementSyntax) - { - if (string.Equals(elementName, elementSyntax.StartTag?.Name?.ToString(), StringComparison.Ordinal)) - { - yield return elementSyntax; - } - } - } - } -} - -public class Accessibility -{ - public Accessibility(SyntaxTokenList modifiers) - { - IsPublic = modifiers.Any(mod => mod.IsKind(SyntaxKind.PublicKeyword)); - IsInternal = modifiers.Any(mod => mod.IsKind(SyntaxKind.InternalKeyword)); - IsStatic = modifiers.Any(mod => mod.IsKind(SyntaxKind.StaticKeyword)); - } - - public bool IsInternal { get; } - - public bool IsPublic { get; } - - public bool IsStatic { get; } } \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/Properties/launchSettings.json b/src/Tools.Analyzers.Core/Properties/launchSettings.json index 78c2ecd4..b9ed4ed0 100644 --- a/src/Tools.Analyzers.Core/Properties/launchSettings.json +++ b/src/Tools.Analyzers.Core/Properties/launchSettings.json @@ -1,7 +1,7 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { - "CodingStandards-Analyzers-Development": { + "Tools-Analyzers-Core-Development": { "commandName": "DebugRoslynComponent", "targetProject": "../ApiHost1/ApiHost1.csproj" } diff --git a/src/Tools.Analyzers.Core/README.md b/src/Tools.Analyzers.Core/README.md new file mode 100644 index 00000000..dc0675b5 --- /dev/null +++ b/src/Tools.Analyzers.Core/README.md @@ -0,0 +1,27 @@ +# Analyzers + +This analyzer project is meant to be included by every project in the solution. It contains several analyzers. + +The individual analyzers will filter the individual projects their analyzers apply to. + +# Development Workarounds + +C# Analyzers have difficulties running in the IDE if the code used in them has dependencies on other projects in the solution (and other nugets). + +This is especially problematic when those referenced projects have transient dependencies to types in .Net or AspNetCore. + +If any dependencies are taken, special workarounds (in the project file of this project) are required in order for the analyzers to work properly. + +We are avoiding including certain types from any projects in this solution (e.g. from the `Infrastructure.WebApi.Common` and `Infrastructure.WebApi.Interfaces` project) even though we need it in the code of the Analyzers, since that project is dependent on types in AspNetCore framework. + +To workaround this, we have file-linked certain source files from projects in the solution, so that we can use those symbols in the Analyzer code. + +We have had to hardcode certain other types to avoid referencing AspNetCore, and these cannot be tracked by tooling if they are changed elsewhere. + +> None of this is ideal. But until we can figure the magic needed to build and run these Analyzers if it uses these types, this may be the best workaround we have for now. + +# Debugging Analyzers + +You can debug the analyzers easily from the unit tests. + +You can debug your analyzers by setting a breakpoint in the code, and then running the `Tools-Analyzers-Core-Development` run configuration from the `Tools.Analyzers.Core` project with the debugger. (found in the `launchSettings.json` file in any executable project). \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/Resources.Designer.cs b/src/Tools.Analyzers.Core/Resources.Designer.cs index 489d1daa..95acb5ed 100644 --- a/src/Tools.Analyzers.Core/Resources.Designer.cs +++ b/src/Tools.Analyzers.Core/Resources.Designer.cs @@ -112,5 +112,194 @@ internal static string SAS002Title { return ResourceManager.GetString("SAS002Title", resourceCulture); } } + + /// <summary> + /// Looks up a localized string similar to This method should return either a Task<T> or T, of a Result type.. + /// </summary> + internal static string SAS010Description { + get { + return ResourceManager.GetString("SAS010Description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to If method '{0}' is supposed to be a service operation, then it should return either: 'Task<ApiEmptyResult>', 'Task<ApiResult<TResource, TResponse>>', 'Task<ApiPostResult<TResource, TResponse>>' or 'ApiEmptyResult', 'ApiResult<TResource, TResponse>' or 'ApiPostResult<TResource, TResponse>'. + /// </summary> + internal static string SAS010MessageFormat { + get { + return ResourceManager.GetString("SAS010MessageFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Wrong return type. + /// </summary> + internal static string SAS010Title { + get { + return ResourceManager.GetString("SAS010Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This service operation should have at least one parameter, and that parameter should be derived from: 'IWebRequest<TResponse>'.. + /// </summary> + internal static string SAS011Description { + get { + return ResourceManager.GetString("SAS011Description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Service operation '{0}' should have at least one parameter of a type derived from; 'IWebRequest<TResponse>'. + /// </summary> + internal static string SAS011MessageFormat { + get { + return ResourceManager.GetString("SAS011MessageFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Missing first parameter or wrong parameter type. + /// </summary> + internal static string SAS011Title { + get { + return ResourceManager.GetString("SAS011Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This service operation can only have a 'CancellationToken' as its second parameter.. + /// </summary> + internal static string SAS012Description { + get { + return ResourceManager.GetString("SAS012Description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Service operation '{0}' can only have a 'CancellationToken' as its second parameter. + /// </summary> + internal static string SAS012MessageFormat { + get { + return ResourceManager.GetString("SAS012MessageFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Wrong second parameter type. + /// </summary> + internal static string SAS012Title { + get { + return ResourceManager.GetString("SAS012Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This service operation should be declared with a 'WebApiRouteAttribute' on it.. + /// </summary> + internal static string SAS013Description { + get { + return ResourceManager.GetString("SAS013Description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Service operation '{0}' should have a 'WebApiRouteAttribute'. + /// </summary> + internal static string SAS013MessageFormat { + get { + return ResourceManager.GetString("SAS013MessageFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Missing 'WebApiRouteAttribute'. + /// </summary> + internal static string SAS013Title { + get { + return ResourceManager.GetString("SAS013Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This service operation has a route declared on it that is different from other service operations in this class.. + /// </summary> + internal static string SAS014Description { + get { + return ResourceManager.GetString("SAS014Description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Service operation '{0}' is required to have the same route path as other service operations in this class. + /// </summary> + internal static string SAS014MessageFormat { + get { + return ResourceManager.GetString("SAS014MessageFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Wrong route group. + /// </summary> + internal static string SAS014Title { + get { + return ResourceManager.GetString("SAS014Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This service operation has the same request type as another service operation in this class.. + /// </summary> + internal static string SAS015Description { + get { + return ResourceManager.GetString("SAS015Description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Service operation '{0}' uses the same type for its first parameter as does another service operation in this class. They must use different request types. + /// </summary> + internal static string SAS015MessageFormat { + get { + return ResourceManager.GetString("SAS015MessageFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Duplicate request type. + /// </summary> + internal static string SAS015Title { + get { + return ResourceManager.GetString("SAS015Title", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to This service operation should return an appropriate result.. + /// </summary> + internal static string SAS016Description { + get { + return ResourceManager.GetString("SAS016Description", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Service operation '{0}' is defined as a '{1}' operation, and should return a 'Task<{2}>' or '{2}'. + /// </summary> + internal static string SAS016MessageFormat { + get { + return ResourceManager.GetString("SAS016MessageFormat", resourceCulture); + } + } + + /// <summary> + /// Looks up a localized string similar to Unexpected return type for operation. + /// </summary> + internal static string SAS016Title { + get { + return ResourceManager.GetString("SAS016Title", resourceCulture); + } + } } } diff --git a/src/Tools.Analyzers.Core/Resources.resx b/src/Tools.Analyzers.Core/Resources.resx index 125f41ca..c9db35ca 100644 --- a/src/Tools.Analyzers.Core/Resources.resx +++ b/src/Tools.Analyzers.Core/Resources.resx @@ -24,7 +24,7 @@ PublicKeyToken=b77a5c561934e089 </value> </resheader> - <!-- ReSharper disable once DuplicateResource --> + <!-- ReSharper disable DuplicateResource --> <data name="SAS001Description" xml:space="preserve"> <value>This type should have a <summary> to describe what it designed to do.</value> </data> @@ -43,4 +43,67 @@ <data name="SAS002Title" xml:space="preserve"> <value>Missing documentation</value> </data> + <data name="SAS010Description" xml:space="preserve"> + <value>This method should return either a Task<T> or T, of a Result type.</value> + </data> + <data name="SAS010MessageFormat" xml:space="preserve"> + <value>If method '{0}' is supposed to be a service operation, then it should return either: 'Task<ApiEmptyResult>', 'Task<ApiResult<TResource, TResponse>>', 'Task<ApiPostResult<TResource, TResponse>>' or 'ApiEmptyResult', 'ApiResult<TResource, TResponse>' or 'ApiPostResult<TResource, TResponse>'</value> + </data> + <data name="SAS010Title" xml:space="preserve"> + <value>Wrong return type</value> + </data> + <data name="SAS011Description" xml:space="preserve"> + <value>This service operation should have at least one parameter, and that parameter should be derived from: 'IWebRequest<TResponse>'.</value> + </data> + <data name="SAS011MessageFormat" xml:space="preserve"> + <value>Service operation '{0}' should have at least one parameter of a type derived from; 'IWebRequest<TResponse>'</value> + </data> + <data name="SAS011Title" xml:space="preserve"> + <value>Missing first parameter or wrong parameter type</value> + </data> + <data name="SAS012Description" xml:space="preserve"> + <value>This service operation can only have a 'CancellationToken' as its second parameter.</value> + </data> + <data name="SAS012MessageFormat" xml:space="preserve"> + <value>Service operation '{0}' can only have a 'CancellationToken' as its second parameter</value> + </data> + <data name="SAS012Title" xml:space="preserve"> + <value>Wrong second parameter type</value> + </data> + <data name="SAS013Description" xml:space="preserve"> + <value>This service operation should be declared with a 'WebApiRouteAttribute' on it.</value> + </data> + <data name="SAS013MessageFormat" xml:space="preserve"> + <value>Service operation '{0}' should have a 'WebApiRouteAttribute'</value> + </data> + <data name="SAS013Title" xml:space="preserve"> + <value>Missing 'WebApiRouteAttribute'</value> + </data> + <data name="SAS014Description" xml:space="preserve"> + <value>This service operation has a route declared on it that is different from other service operations in this class.</value> + </data> + <data name="SAS014MessageFormat" xml:space="preserve"> + <value>Service operation '{0}' is required to have the same route path as other service operations in this class</value> + </data> + <data name="SAS014Title" xml:space="preserve"> + <value>Wrong route group</value> + </data> + <data name="SAS015Description" xml:space="preserve"> + <value>This service operation has the same request type as another service operation in this class.</value> + </data> + <data name="SAS015MessageFormat" xml:space="preserve"> + <value>Service operation '{0}' uses the same type for its first parameter as does another service operation in this class. They must use different request types</value> + </data> + <data name="SAS015Title" xml:space="preserve"> + <value>Duplicate request type</value> + </data> + <data name="SAS016Description" xml:space="preserve"> + <value>This service operation should return an appropriate result.</value> + </data> + <data name="SAS016MessageFormat" xml:space="preserve"> + <value>Service operation '{0}' is defined as a '{1}' operation, and should return a 'Task<{2}>' or '{2}'</value> + </data> + <data name="SAS016Title" xml:space="preserve"> + <value>Unexpected return type for operation</value> + </data> </root> \ No newline at end of file diff --git a/src/Tools.Analyzers.Core/Tools.Analyzers.Core.csproj b/src/Tools.Analyzers.Core/Tools.Analyzers.Core.csproj index 4e276815..fe23c27a 100644 --- a/src/Tools.Analyzers.Core/Tools.Analyzers.Core.csproj +++ b/src/Tools.Analyzers.Core/Tools.Analyzers.Core.csproj @@ -4,16 +4,17 @@ <TargetFramework>.net7.0</TargetFramework> <IsRoslynComponent>true</IsRoslynComponent> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> - <GeneratePackageOnBuild>true</GeneratePackageOnBuild> + <GeneratePackageOnBuild>false</GeneratePackageOnBuild> <IsPackable>true</IsPackable> <IncludeBuildOutput>false</IncludeBuildOutput> <PackageId>SaaStack.Tools.Analyzers.Core</PackageId> <Description>Roslyn analyzers for SaaStack codebases</Description> <PackageReleaseNotes>https://github.com/jezzsantos/saastack/blob/main/src/Tools.Analyzers.Core/README.md</PackageReleaseNotes> + <PackageVersion>1.0.0</PackageVersion> </PropertyGroup> <PropertyGroup> - <NoWarn>RS2007;NU5128</NoWarn> + <NoWarn>$(NoWarn),RS2007;NU5128</NoWarn> </PropertyGroup> <ItemGroup> @@ -45,6 +46,42 @@ <AdditionalFiles Remove="AnalyzerReleases.Unshipped.md" /> </ItemGroup> + <ItemGroup> + <Compile Include="..\Infrastructure.WebApi.Interfaces\IWebApiService.cs"> + <Link>Reference\Infrastructure.WebApi.Interfaces\IWebApiService.cs</Link> + </Compile> + <Compile Include="..\Infrastructure.WebApi.Interfaces\IWebResponse.cs"> + <Link>Reference\Infrastructure.WebApi.Interfaces\IWebResponse.cs</Link> + </Compile> + <Compile Include="..\Infrastructure.WebApi.Interfaces\WebApiOperation.cs"> + <Link>Reference\Infrastructure.WebApi.Interfaces\WebApiOperation.cs</Link> + </Compile> + <Compile Include="..\Infrastructure.WebApi.Interfaces\WebApiRouteAttribute.cs"> + <Link>Reference\Infrastructure.WebApi.Interfaces\WebApiRouteAttribute.cs</Link> + </Compile> + <Compile Include="..\Infrastructure.WebApi.Interfaces\EmptyResponse.cs"> + <Link>Reference\Infrastruture.WebApi.Interfaces\EmptyResponse.cs</Link> + </Compile> + <Compile Include="..\Infrastructure.WebApi.Common\ApiResult.cs"> + <Link>Reference\Infrastruture.WebApi.Common\ApiResult.cs</Link> + </Compile> + <Compile Include="..\Common\Result.cs"> + <Link>Reference\Common\Result.cs</Link> + </Compile> + <Compile Include="..\Common\Optional.cs"> + <Link>Reference\Common\Optional.cs</Link> + </Compile> + <Compile Include="..\Common\Error.cs"> + <Link>Reference\Common\Error.cs</Link> + </Compile> + <Compile Include="..\Common\Annotations.cs"> + <Link>Reference\Common\Annotations.cs</Link> + </Compile> + <Compile Include="..\Common\Resources.Designer.cs"> + <Link>Reference\Common\Resources.Designer.cs</Link> + </Compile> + </ItemGroup> + <ItemGroup> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup> @@ -54,4 +91,10 @@ SourceFiles="$(OutputPath)../$(PackageId).$(PackageVersion).nupkg" DestinationFolder="../../tools/nuget" /> </Target> + + <ItemGroup> + <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute"> + <_Parameter1>$(AssemblyName).UnitTests</_Parameter1> + </AssemblyAttribute> + </ItemGroup> </Project> diff --git a/src/Tools.Analyzers.Core/WebApiClassAnalyzer.cs b/src/Tools.Analyzers.Core/WebApiClassAnalyzer.cs new file mode 100644 index 00000000..67e5759b --- /dev/null +++ b/src/Tools.Analyzers.Core/WebApiClassAnalyzer.cs @@ -0,0 +1,269 @@ +using System.Collections.Immutable; +using Common.Extensions; +using Infrastructure.WebApi.Common; +using Infrastructure.WebApi.Interfaces; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Tools.Analyzers.Core.Extensions; + +// ReSharper disable InvalidXmlDocComment + +namespace Tools.Analyzers.Core; + +/// <summary> +/// An analyzer to ensure that WebAPI classes are configured correctly. +/// SAS010. Warning: Methods that are public, should return a <see cref="Task{T}" /> or just any T, where T is either: +/// <see cref="ApiEmptyResult" /> or <see cref="ApiResult{TResource, TResponse}" /> or +/// <see cref="ApiPostResult{TResource, TResponse}" /> +/// SAS011. Warning: These methods must have at least one parameter, and first parameter must be +/// <see cref="IWebRequest{TResponse}" />, where +/// TResponse is same type as in the return value. +/// SAS012. Warning: The second parameter can only be a <see cref="CancellationToken" /> +/// SAS013. Warning: These methods must be decorated with a <see cref="WebApiRouteAttribute" /> +/// SAS014. Warning: The route (of all these methods in this class) should start with the same path +/// SAS015. Warning: There should be no methods in this class with the same <see cref="IWebRequest{TResponse}" /> +/// </summary> +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class WebApiClassAnalyzer : DiagnosticAnalyzer +{ + internal static readonly DiagnosticDescriptor Sas010 = "SAS010".GetDescriptor(DiagnosticSeverity.Warning, + AnalyzerConstants.Categories.WebApi, nameof(Resources.SAS010Title), nameof(Resources.SAS010Description), + nameof(Resources.SAS010MessageFormat)); + + internal static readonly DiagnosticDescriptor Sas011 = "SAS011".GetDescriptor(DiagnosticSeverity.Warning, + AnalyzerConstants.Categories.WebApi, nameof(Resources.SAS011Title), nameof(Resources.SAS011Description), + nameof(Resources.SAS011MessageFormat)); + + internal static readonly DiagnosticDescriptor Sas012 = "SAS012".GetDescriptor(DiagnosticSeverity.Warning, + AnalyzerConstants.Categories.WebApi, nameof(Resources.SAS012Title), nameof(Resources.SAS012Description), + nameof(Resources.SAS012MessageFormat)); + + internal static readonly DiagnosticDescriptor Sas013 = "SAS013".GetDescriptor(DiagnosticSeverity.Warning, + AnalyzerConstants.Categories.WebApi, nameof(Resources.SAS013Title), nameof(Resources.SAS013Description), + nameof(Resources.SAS013MessageFormat)); + + internal static readonly DiagnosticDescriptor Sas014 = "SAS014".GetDescriptor(DiagnosticSeverity.Warning, + AnalyzerConstants.Categories.WebApi, nameof(Resources.SAS014Title), nameof(Resources.SAS014Description), + nameof(Resources.SAS014MessageFormat)); + + internal static readonly DiagnosticDescriptor Sas015 = "SAS015".GetDescriptor(DiagnosticSeverity.Warning, + AnalyzerConstants.Categories.WebApi, nameof(Resources.SAS015Title), nameof(Resources.SAS015Description), + nameof(Resources.SAS015MessageFormat)); + + internal static readonly DiagnosticDescriptor Sas016 = "SAS016".GetDescriptor(DiagnosticSeverity.Warning, + AnalyzerConstants.Categories.WebApi, nameof(Resources.SAS016Title), nameof(Resources.SAS016Description), + nameof(Resources.SAS016MessageFormat)); + + public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => + ImmutableArray.Create(Sas010, Sas011, Sas012, Sas013, Sas014, Sas015, Sas016); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeClass, SyntaxKind.ClassDeclaration); + } + + private static void AnalyzeClass(SyntaxNodeAnalysisContext context) + { + var methodSyntax = context.Node; + if (methodSyntax is not ClassDeclarationSyntax classDeclarationSyntax) + { + return; + } + + if (context.IsExcludedInNamespace(AnalyzerConstants.CommonNamespaces)) + { + return; + } + + if (classDeclarationSyntax.IsNotType<IWebApiService>(context)) + { + return; + } + + var allMethods = classDeclarationSyntax.Members.Where(member => member is MethodDeclarationSyntax) + .Cast<MethodDeclarationSyntax>(); + var operations = new Dictionary<MethodDeclarationSyntax, ServiceOperation>(); + foreach (var methodDeclarationSyntax in allMethods) + { + if (methodDeclarationSyntax.IsNotPublicInstanceMethod()) + { + continue; + } + + if (ReturnTypeIsNotCorrect(context, methodDeclarationSyntax, out var returnType)) + { + continue; + } + + if (ParametersAreInCorrect(context, methodDeclarationSyntax, out var requestType)) + { + continue; + } + + operations.Add(methodDeclarationSyntax, new ServiceOperation(requestType!)); + + if (AttributeIsNotPresent(context, methodDeclarationSyntax, out var attribute)) + { + continue; + } + + var operation = (WebApiOperation)attribute!.ConstructorArguments[1].Value!; + if (OperationAndReturnsTypeDontMatch(context, methodDeclarationSyntax, operation, returnType!)) + { + continue; + } + + var routePath = attribute.ConstructorArguments[0] + .Value?.ToString(); + operations[methodDeclarationSyntax] + .SetRouteSegments(routePath); + } + + RoutesHaveSamePrimaryResource(context, operations); + RequestTypesAreNotDuplicated(context, operations); + } + + private static bool OperationAndReturnsTypeDontMatch(SyntaxNodeAnalysisContext context, + MethodDeclarationSyntax methodDeclarationSyntax, WebApiOperation operation, ITypeSymbol returnType) + { + if (operation != WebApiOperation.Post) + { + return false; + } + + var type = typeof(ApiPostResult<,>); + var postResult = context.Compilation.GetTypeByMetadataName(type.FullName!)!; + if (!SymbolEqualityComparer.Default.Equals(returnType.OriginalDefinition, postResult)) + { + var typeName = + "ApiPostResult<TResource, TResponse>"; // HACK: dont know how to get this same string from the type itself + context.ReportDiagnostic(Sas016, methodDeclarationSyntax, operation, typeName); + return true; + } + + return false; + } + + private static bool ReturnTypeIsNotCorrect(SyntaxNodeAnalysisContext context, + MethodDeclarationSyntax methodDeclarationSyntax, out ITypeSymbol? returnType) + { + if (methodDeclarationSyntax.IsReturnTypeNotMatching(context, out returnType, typeof(ApiEmptyResult), + typeof(ApiResult<,>), typeof(ApiPostResult<,>))) + { + context.ReportDiagnostic(Sas010, methodDeclarationSyntax); + return true; + } + + return false; + } + + private static bool AttributeIsNotPresent(SyntaxNodeAnalysisContext context, + MethodDeclarationSyntax methodDeclarationSyntax, out AttributeData? attribute) + { + attribute = null; + var attributes = methodDeclarationSyntax.AttributeLists; + if (attributes.Count == 0) + { + context.ReportDiagnostic(Sas013, methodDeclarationSyntax); + return true; + } + + attribute = methodDeclarationSyntax.GetAttributeOfType<WebApiRouteAttribute>(context); + if (attribute is null) + { + context.ReportDiagnostic(Sas013, methodDeclarationSyntax); + return true; + } + + return false; + } + + private static bool ParametersAreInCorrect(SyntaxNodeAnalysisContext context, + MethodDeclarationSyntax methodDeclarationSyntax, out ITypeSymbol? requestType) + { + requestType = null; + var parameters = methodDeclarationSyntax.ParameterList.Parameters; + if (parameters.Count is < 1 or > 2) + { + context.ReportDiagnostic(Sas011, methodDeclarationSyntax); + return true; + } + + var firstParam = parameters.First(); + requestType = firstParam.GetBaseOfType<IWebRequest>(context); + if (requestType is null) + { + context.ReportDiagnostic(Sas011, methodDeclarationSyntax); + return true; + } + + if (parameters.Count == 2) + { + var secondParam = parameters[1]; + if (secondParam.IsNotType<CancellationToken>(context)) + { + context.ReportDiagnostic(Sas012, methodDeclarationSyntax); + return true; + } + } + + return false; + } + + private static void RequestTypesAreNotDuplicated(SyntaxNodeAnalysisContext context, + Dictionary<MethodDeclarationSyntax, ServiceOperation> operations) + { + var duplicateRequestTypes = operations.GroupBy(ops => ops.Value.RequestType.ToDisplayString()) + .Where(grp => grp.Count() > 1); + foreach (var duplicateGroup in duplicateRequestTypes) + { + foreach (var entry in duplicateGroup) + { + context.ReportDiagnostic(Sas015, entry.Key); + } + } + } + + private static void RoutesHaveSamePrimaryResource(SyntaxNodeAnalysisContext context, + Dictionary<MethodDeclarationSyntax, ServiceOperation> operations) + { + var primaryResource = string.Empty; + foreach (var operation in operations.Where(ops => ops.Value.RouteSegments.Any())) + { + if (primaryResource.HasNoValue()) + { + primaryResource = operation.Value.RouteSegments.First(); + continue; + } + + if (operation.Value.RouteSegments.First() != primaryResource) + { + context.ReportDiagnostic(Sas014, operation.Key); + } + } + } + + private class ServiceOperation + { + public ServiceOperation(ITypeSymbol requestType) + { + RequestType = requestType; + } + + public ITypeSymbol RequestType { get; } + + public IEnumerable<string> RouteSegments { get; private set; } = Enumerable.Empty<string>(); + + public void SetRouteSegments(string? routePath) + { + if (routePath.HasValue()) + { + RouteSegments = routePath!.Split("/", StringSplitOptions.RemoveEmptyEntries); + } + } + } +} \ No newline at end of file diff --git a/src/Tools.Generators.WebApi/README.md b/src/Tools.Generators.WebApi/README.md index d0e2e418..bbd522d7 100644 --- a/src/Tools.Generators.WebApi/README.md +++ b/src/Tools.Generators.WebApi/README.md @@ -12,7 +12,7 @@ This is especially problematic when those referenced projects have transient dep If any dependencies are taken, special workarounds (in the project file of this project) are required in order for this source generators to work properly. -We are avoiding including certain types from any projects in this solution (e.g. from the `Infrastructure.WebApi.Interfaces` project) even though we need in the code of the Source generator, since that project is dependent on types in AspNetCore framework. +We are avoiding including certain types from any projects in this solution (e.g. from the `Infrastructure.WebApi.Interfaces` project) even though we need it in the code of the Source generator, since that project is dependent on types in AspNetCore framework. To workaround this, we have file-linked certain source files from projects in the solution, so that we can use those symbols in the Source Generator code. diff --git a/src/Tools.Generators.WebApi/Tools.Generators.WebApi.csproj b/src/Tools.Generators.WebApi/Tools.Generators.WebApi.csproj index e77bea4c..a816920b 100644 --- a/src/Tools.Generators.WebApi/Tools.Generators.WebApi.csproj +++ b/src/Tools.Generators.WebApi/Tools.Generators.WebApi.csproj @@ -13,16 +13,16 @@ <ItemGroup> <Compile Include="..\Infrastructure.WebApi.Interfaces\IWebApiService.cs"> - <Link>Reference\Infrastruture.WebApi.Interfaces\IWebApiService.cs</Link> + <Link>Reference\Infrastructure.WebApi.Interfaces\IWebApiService.cs</Link> </Compile> <Compile Include="..\Infrastructure.WebApi.Interfaces\IWebResponse.cs"> - <Link>Reference\Infrastruture.WebApi.Interfaces\IWebResponse.cs</Link> + <Link>Reference\Infrastructure.WebApi.Interfaces\IWebResponse.cs</Link> </Compile> <Compile Include="..\Infrastructure.WebApi.Interfaces\WebApiOperation.cs"> - <Link>Reference\Infrastruture.WebApi.Interfaces\WebApiOperation.cs</Link> + <Link>Reference\Infrastructure.WebApi.Interfaces\WebApiOperation.cs</Link> </Compile> <Compile Include="..\Infrastructure.WebApi.Interfaces\WebApiRouteAttribute.cs"> - <Link>Reference\Infrastruture.WebApi.Interfaces\WebApiRouteAttribute.cs</Link> + <Link>Reference\Infrastructure.WebApi.Interfaces\WebApiRouteAttribute.cs</Link> </Compile> </ItemGroup> </Project> diff --git a/tools/nuget/SaaStack.Tools.Analyzers.Core.1.0.0.nupkg b/tools/nuget/SaaStack.Tools.Analyzers.Core.1.0.0.nupkg index 82e3f205..46c0ea26 100644 Binary files a/tools/nuget/SaaStack.Tools.Analyzers.Core.1.0.0.nupkg and b/tools/nuget/SaaStack.Tools.Analyzers.Core.1.0.0.nupkg differ