From 6c6fcd48eb535ceb24c75bdb780f8f68b02306a4 Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Fri, 6 Oct 2023 10:59:02 +1300 Subject: [PATCH] Added Analyzer rules for writing Api classes --- src/Directory.Build.props | 26 +- src/Infrastructure.WebApi.Common/ApiResult.cs | 51 ++ .../HandlerExtensions.cs | 47 -- src/SaaStack.sln.DotSettings | 6 + .../MissingDocsAnalyzerSpec.cs | 86 +- .../Tools.Analyzers.Core.UnitTests.csproj | 6 +- src/Tools.Analyzers.Core.UnitTests/Verify.cs | 96 ++- .../WebApiClassAnalyzerSpec.cs | 779 ++++++++++++++++++ src/Tools.Analyzers.Core/AnalyzerConstants.cs | 22 + .../AnalyzerReleases.Shipped.md | 15 +- .../Extensions/DiagnosticExtensions.cs | 53 ++ .../Extensions/ObjectExtensions.cs | 16 + .../Extensions/ResourceExtensions.cs | 11 + .../Extensions/StringExtensions.cs | 33 + .../Extensions/SyntaxExtensions.cs | 50 ++ .../Extensions/SyntaxFilterExtensions.cs | 317 +++++++ src/Tools.Analyzers.Core/IWebRequest.cs | 19 + .../MissingDocsAnalyzer.cs | 292 +------ .../Properties/launchSettings.json | 2 +- src/Tools.Analyzers.Core/README.md | 27 + .../Resources.Designer.cs | 189 +++++ src/Tools.Analyzers.Core/Resources.resx | 65 +- .../Tools.Analyzers.Core.csproj | 47 +- .../WebApiClassAnalyzer.cs | 269 ++++++ src/Tools.Generators.WebApi/README.md | 2 +- .../Tools.Generators.WebApi.csproj | 8 +- .../SaaStack.Tools.Analyzers.Core.1.0.0.nupkg | Bin 10270 -> 27079 bytes 27 files changed, 2144 insertions(+), 390 deletions(-) create mode 100644 src/Infrastructure.WebApi.Common/ApiResult.cs create mode 100644 src/Tools.Analyzers.Core.UnitTests/WebApiClassAnalyzerSpec.cs create mode 100644 src/Tools.Analyzers.Core/AnalyzerConstants.cs create mode 100644 src/Tools.Analyzers.Core/Extensions/DiagnosticExtensions.cs create mode 100644 src/Tools.Analyzers.Core/Extensions/ObjectExtensions.cs create mode 100644 src/Tools.Analyzers.Core/Extensions/ResourceExtensions.cs create mode 100644 src/Tools.Analyzers.Core/Extensions/StringExtensions.cs create mode 100644 src/Tools.Analyzers.Core/Extensions/SyntaxExtensions.cs create mode 100644 src/Tools.Analyzers.Core/Extensions/SyntaxFilterExtensions.cs create mode 100644 src/Tools.Analyzers.Core/IWebRequest.cs create mode 100644 src/Tools.Analyzers.Core/README.md create mode 100644 src/Tools.Analyzers.Core/WebApiClassAnalyzer.cs 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 @@ false false - false - 436,NU5125 - Debug;Release;ReleaseForDeploy - AnyCPU + + + true + $(NoWarn),1573,1574,1591,1712,1723 + + + + + + + + false + $(NoWarn),436,NU5125 + Debug;Release;ReleaseForDeploy + AnyCPU Debug AnyCPU @@ -95,11 +106,4 @@ 4 - - - - analyzers - - - 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; + +/// +/// Defines an callback that relates a containing a +/// +/// +// ReSharper disable once UnusedTypeParameter +public delegate Result, Error> ApiPostResult() + where TResource : class where TResponse : IWebResponse; + +/// +/// Defines an callback that relates a containing a +/// +/// +// ReSharper disable once UnusedTypeParameter +public delegate Result ApiResult() + where TResource : class where TResponse : IWebResponse; + +/// +/// Defines an callback that relates a +/// +public delegate Result ApiEmptyResult(); + +/// +/// Provides a container with a and other attributes describing a +/// +/// +public class PostResult + where TResponse : IWebResponse +{ + public PostResult(TResponse response, string? location = null) + { + Response = response; + Location = location; + } + + public string? Location { get; } + + public TResponse Response { get; } + + /// + /// Converts the into a + /// + public static implicit operator PostResult(TResponse response) + { + return new PostResult(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; -/// -/// Defines an callback that relates a containing a -/// -/// -// ReSharper disable once UnusedTypeParameter -public delegate Result, Error> ApiPostResult() - where TResource : class where TResponse : IWebResponse; - -/// -/// Defines an callback that relates a containing a -/// -/// -// ReSharper disable once UnusedTypeParameter -public delegate Result ApiResult() - where TResource : class where TResponse : IWebResponse; - -/// -/// Defines an callback that relates a -/// -public delegate Result ApiEmptyResult(); - -/// -/// Provides a container with a and other attributes describing a -/// -/// -public class PostResult - where TResponse : IWebResponse -{ - public PostResult(TResponse response, string? location = null) - { - Response = response; - Location = location; - } - - public string? Location { get; } - - public TResponse Response { get; } - - /// - /// Converts the into a - /// - public static implicit operator PostResult(TResponse response) - { - return new PostResult(response); - } -} - public static class HandlerExtensions { /// 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 @@ WARNING WARNING + DO_NOT_SHOW WARNING WARNING Required @@ -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$() True True True + True True True True True + True True True True 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(input); } [Fact] @@ -28,7 +30,7 @@ public class AClass { }"; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(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.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.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.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.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.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.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.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.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.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.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.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.Sas001, input, 1, 17, "ARecord"); } [Fact] @@ -155,7 +157,7 @@ public async Task WhenPublicStaticClass_ThenNoAlert() } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(input); } [Fact] @@ -166,7 +168,7 @@ public async Task WhenInternalStaticClass_ThenNoAlert() } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(input); } [Fact] @@ -180,7 +182,7 @@ public static class AClass2 } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(input); } [Fact] @@ -198,7 +200,7 @@ private static class AClass2 } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(input); } [Fact] @@ -216,7 +218,7 @@ public class AClass2 } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 7, 18, "AClass2"); + await Verify.DiagnosticExists(MissingDocsAnalyzer.Sas001, input, 7, 18, "AClass2"); } [Fact] @@ -234,7 +236,7 @@ private class AClass2 } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(input); } [Fact] @@ -245,7 +247,7 @@ public async Task WhenPublicClassNoSummary_ThenAlerts() } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.TypeDiagnosticId, input, 1, 14); + await Verify.DiagnosticExists(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.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.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.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.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.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.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.Sas001, input, 5, 14, "AClass"); } [Fact] @@ -356,12 +358,12 @@ public class AClass } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(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(input); } [Fact] @@ -384,7 +386,7 @@ public static class AClass public static void AMethod(){} }"; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(input); } [Fact] @@ -396,7 +398,7 @@ public static void AMethod1(){} public static void AMethod2(this string value){} }"; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(input); } [Fact] @@ -408,7 +410,7 @@ public static void AMethod(){} } "; - await Verify.DiagnosticExists(MissingDocsAnalyzer.ExtensionMethodDiagnosticId, input, 3, 24, "AMethod"); + await Verify.DiagnosticExists(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.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.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.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.Sas002, input, 3, 26, "AMethod"); } [Fact] @@ -468,7 +470,7 @@ private static void AMethod(this string value){} } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(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.Sas002, input, 3, 24, "AMethod"); } [Fact] @@ -496,7 +498,7 @@ private static void AMethod(this string value){} } "; - await Verify.NoDiagnosticExists(input); + await Verify.NoDiagnosticExists(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 @@ - + - - - - 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.Diagnostic(diagnosticId) - .WithLocation(locationX, locationY) - .WithArguments(argument); - await CSharpAnalyzerVerifier.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 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(DiagnosticDescriptor descriptor, string inputSnippet, + int locationX, int locationY, string argument, params object?[]? messageArgs) + where TAnalyzer : DiagnosticAnalyzer, new() + { + await DiagnosticExists(descriptor, inputSnippet, (locationX, locationY, argument), messageArgs); + } + + public static async Task DiagnosticExists(DiagnosticDescriptor descriptor, string inputSnippet, + (int, int, string) expected1, (int, int, string) expected2) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var expectation1 = CSharpAnalyzerVerifier.Diagnostic(descriptor) + .WithLocation(expected1.Item1, expected1.Item2) + .WithArguments(expected1.Item3); + var expectation2 = CSharpAnalyzerVerifier.Diagnostic(descriptor) + .WithLocation(expected2.Item1, expected2.Item2) + .WithArguments(expected2.Item3); + + await RunAnalyzerTest(inputSnippet, new[] { expectation1, expectation2 }); } - public static async Task NoDiagnosticExists(string inputSnippet) + public static async Task NoDiagnosticExists(string inputSnippet) + where TAnalyzer : DiagnosticAnalyzer, new() { - await CSharpAnalyzerVerifier.VerifyAnalyzerAsync(inputSnippet); + await RunAnalyzerTest(inputSnippet, null); + } + + private static async Task DiagnosticExists(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.Diagnostic(descriptor) + .WithLocation(expected1.Item1, expected1.Item2) + .WithArguments(arguments.ToArray()!); + + await RunAnalyzerTest(inputSnippet, new[] { expectation }); + } + + private static async Task RunAnalyzerTest(string inputSnippet, DiagnosticResult[]? expected) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var analyzerTest = new CSharpAnalyzerTest(); + 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(input); + } + + [Fact] + public async Task WhenNotWebApiClass_ThenNoAlert() + { + const string input = @" +namespace ANamespace; +public class AClass +{ +}"; + + await Verify.NoDiagnosticExists(input); + } + + [Fact] + public async Task WhenHasNoMethods_ThenNoAlert() + { + const string input = @" +using Infrastructure.WebApi.Interfaces; +namespace ANamespace; +public class AClass : IWebApiService +{ +}"; + + await Verify.NoDiagnosticExists(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(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(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.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.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 AMethod(){ return Task.FromResult(""""); } +}"; + + await Verify.DiagnosticExists(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 AMethod(TestRequest1 request) + { + return Task.FromResult(() => new Result()); + } +}"; + + await Verify.NoDiagnosticExists(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> AMethod(TestRequest1 request) + { + return Task.FromResult>(() => new Result()); + } +}"; + + await Verify.NoDiagnosticExists(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> AMethod(TestRequest1 request) + { + return Task.FromResult>(() => new Result, Error>()); + } +}"; + + await Verify.NoDiagnosticExists(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.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(); + } +}"; + + await Verify.NoDiagnosticExists(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 AMethod(TestRequest1 request) + { + return () => new Result(); + } +}"; + + await Verify.NoDiagnosticExists(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 AMethod(TestRequest1 request) + { + return () => new Result, Error>(); + } +}"; + + await Verify.NoDiagnosticExists(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(); + } +}"; + + await Verify.DiagnosticExists(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(); + } +}"; + + await Verify.DiagnosticExists(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(); + } +}"; + + await Verify.DiagnosticExists(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(); + } +}"; + + await Verify.DiagnosticExists(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(); + } +}"; + + await Verify.NoDiagnosticExists(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(); + } +}"; + + await Verify.NoDiagnosticExists(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(); + } +}"; + + await Verify.DiagnosticExists(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(); + } +}"; + + await Verify.DiagnosticExists(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(); + } +}"; + + await Verify.NoDiagnosticExists(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(); + } +}"; + + await Verify.NoDiagnosticExists(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(); + } + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod2(TestRequest2 request) + { + return () => new Result(); + } +}"; + + await Verify.NoDiagnosticExists(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(); + } + [WebApiRoute(""/aresource/2"", WebApiOperation.Get)] + public ApiEmptyResult AMethod2(TestRequest2 request) + { + return () => new Result(); + } + [WebApiRoute(""/aresource/3"", WebApiOperation.Get)] + public ApiEmptyResult AMethod3(TestRequest3 request) + { + return () => new Result(); + } +}"; + + await Verify.NoDiagnosticExists(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(); + } + [WebApiRoute(""/aresource/2"", WebApiOperation.Get)] + public ApiEmptyResult AMethod2(TestRequest2 request) + { + return () => new Result(); + } + [WebApiRoute(""/anotherresource/1"", WebApiOperation.Get)] + public ApiEmptyResult AMethod3(TestRequest3 request) + { + return () => new Result(); + } +}"; + + await Verify.DiagnosticExists(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(); + } +}"; + + await Verify.NoDiagnosticExists(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(); + } + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod2(TestRequest1 request) + { + return () => new Result(); + } + [WebApiRoute(""/aresource"", WebApiOperation.Get)] + public ApiEmptyResult AMethod3(TestRequest2 request) + { + return () => new Result(); + } +}"; + + await Verify.DiagnosticExists(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(); + } +}"; + + await Verify.NoDiagnosticExists(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(); + } +}"; + + await Verify.NoDiagnosticExists(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 AMethod(TestRequest1 request) + { + return () => new PostResult(new TestResponse(), ""/alocation""); + } +}"; + + await Verify.NoDiagnosticExists(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(); + } +}"; + + await Verify.DiagnosticExists(WebApiClassAnalyzer.Sas016, input, 11, 27, "AMethod1", + WebApiOperation.Post, "ApiPostResult"); + } + } +} + +[UsedImplicitly] +public class TestResource +{ +} + +[UsedImplicitly] +public class TestResponse : IWebResponse +{ +} + +[UsedImplicitly] +public class TestRequest1 : IWebRequest +{ +} + +[UsedImplicitly] +public class TestRequest2 : IWebRequest +{ +} + +[UsedImplicitly] +public class TestRequest3 : IWebRequest +{ +} + +[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 + "", +#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 +{ + /// + /// Whether the object does not exist + /// + [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 +{ + /// + /// Whether the string value contains no value: it is either: null, empty or only whitespaces + /// + [ContractAnnotation("null => true; notnull => false")] + public static bool HasNoValue(this string? value) + { + return string.IsNullOrEmpty(value) || string.IsNullOrWhiteSpace(value); + } + + /// + /// Whether the string value contains any value except: null, empty or only whitespaces + /// + [ContractAnnotation("null => false; notnull => true")] + public static bool HasValue(this string? value) + { + return !string.IsNullOrEmpty(value) && !string.IsNullOrWhiteSpace(value); + } + + /// + /// Formats the with the + /// + 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 content, string elementName) + { + return content.GetXmlElements(elementName) + .FirstOrDefault(); + } + + private static IEnumerable GetXmlElements(this SyntaxList 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(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(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(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(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 + 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; + +/// +/// Defines a incoming REST request and response. +/// +public interface IWebRequest +{ +} + +/// +/// Defines a incoming REST request and response. +/// +// ReSharper disable once UnusedTypeParameter +public interface IWebRequest : 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; /// /// 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) /// [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 - "", -#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 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 content, string elementName) - { - return content.GetXmlElements(elementName) - .FirstOrDefault(); - } - - private static IEnumerable GetXmlElements(this SyntaxList 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); } } + + /// + /// Looks up a localized string similar to This method should return either a Task<T> or T, of a Result type.. + /// + internal static string SAS010Description { + get { + return ResourceManager.GetString("SAS010Description", resourceCulture); + } + } + + /// + /// 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>'. + /// + internal static string SAS010MessageFormat { + get { + return ResourceManager.GetString("SAS010MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wrong return type. + /// + internal static string SAS010Title { + get { + return ResourceManager.GetString("SAS010Title", resourceCulture); + } + } + + /// + /// 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>'.. + /// + internal static string SAS011Description { + get { + return ResourceManager.GetString("SAS011Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Service operation '{0}' should have at least one parameter of a type derived from; 'IWebRequest<TResponse>'. + /// + internal static string SAS011MessageFormat { + get { + return ResourceManager.GetString("SAS011MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing first parameter or wrong parameter type. + /// + internal static string SAS011Title { + get { + return ResourceManager.GetString("SAS011Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This service operation can only have a 'CancellationToken' as its second parameter.. + /// + internal static string SAS012Description { + get { + return ResourceManager.GetString("SAS012Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Service operation '{0}' can only have a 'CancellationToken' as its second parameter. + /// + internal static string SAS012MessageFormat { + get { + return ResourceManager.GetString("SAS012MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wrong second parameter type. + /// + internal static string SAS012Title { + get { + return ResourceManager.GetString("SAS012Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This service operation should be declared with a 'WebApiRouteAttribute' on it.. + /// + internal static string SAS013Description { + get { + return ResourceManager.GetString("SAS013Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Service operation '{0}' should have a 'WebApiRouteAttribute'. + /// + internal static string SAS013MessageFormat { + get { + return ResourceManager.GetString("SAS013MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing 'WebApiRouteAttribute'. + /// + internal static string SAS013Title { + get { + return ResourceManager.GetString("SAS013Title", resourceCulture); + } + } + + /// + /// 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.. + /// + internal static string SAS014Description { + get { + return ResourceManager.GetString("SAS014Description", resourceCulture); + } + } + + /// + /// 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. + /// + internal static string SAS014MessageFormat { + get { + return ResourceManager.GetString("SAS014MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wrong route group. + /// + internal static string SAS014Title { + get { + return ResourceManager.GetString("SAS014Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This service operation has the same request type as another service operation in this class.. + /// + internal static string SAS015Description { + get { + return ResourceManager.GetString("SAS015Description", resourceCulture); + } + } + + /// + /// 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. + /// + internal static string SAS015MessageFormat { + get { + return ResourceManager.GetString("SAS015MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Duplicate request type. + /// + internal static string SAS015Title { + get { + return ResourceManager.GetString("SAS015Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to This service operation should return an appropriate result.. + /// + internal static string SAS016Description { + get { + return ResourceManager.GetString("SAS016Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Service operation '{0}' is defined as a '{1}' operation, and should return a 'Task<{2}>' or '{2}'. + /// + internal static string SAS016MessageFormat { + get { + return ResourceManager.GetString("SAS016MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unexpected return type for operation. + /// + 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 - + This type should have a <summary> to describe what it designed to do. @@ -43,4 +43,67 @@ Missing documentation + + This method should return either a Task<T> or T, of a Result type. + + + 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>' + + + Wrong return type + + + This service operation should have at least one parameter, and that parameter should be derived from: 'IWebRequest<TResponse>'. + + + Service operation '{0}' should have at least one parameter of a type derived from; 'IWebRequest<TResponse>' + + + Missing first parameter or wrong parameter type + + + This service operation can only have a 'CancellationToken' as its second parameter. + + + Service operation '{0}' can only have a 'CancellationToken' as its second parameter + + + Wrong second parameter type + + + This service operation should be declared with a 'WebApiRouteAttribute' on it. + + + Service operation '{0}' should have a 'WebApiRouteAttribute' + + + Missing 'WebApiRouteAttribute' + + + This service operation has a route declared on it that is different from other service operations in this class. + + + Service operation '{0}' is required to have the same route path as other service operations in this class + + + Wrong route group + + + This service operation has the same request type as another service operation in this class. + + + 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 + + + Duplicate request type + + + This service operation should return an appropriate result. + + + Service operation '{0}' is defined as a '{1}' operation, and should return a 'Task<{2}>' or '{2}' + + + Unexpected return type for operation + \ 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 @@ .net7.0 true true - true + false true false SaaStack.Tools.Analyzers.Core Roslyn analyzers for SaaStack codebases https://github.com/jezzsantos/saastack/blob/main/src/Tools.Analyzers.Core/README.md + 1.0.0 - RS2007;NU5128 + $(NoWarn),RS2007;NU5128 @@ -45,6 +46,42 @@ + + + Reference\Infrastructure.WebApi.Interfaces\IWebApiService.cs + + + Reference\Infrastructure.WebApi.Interfaces\IWebResponse.cs + + + Reference\Infrastructure.WebApi.Interfaces\WebApiOperation.cs + + + Reference\Infrastructure.WebApi.Interfaces\WebApiRouteAttribute.cs + + + Reference\Infrastruture.WebApi.Interfaces\EmptyResponse.cs + + + Reference\Infrastruture.WebApi.Common\ApiResult.cs + + + Reference\Common\Result.cs + + + Reference\Common\Optional.cs + + + Reference\Common\Error.cs + + + Reference\Common\Annotations.cs + + + Reference\Common\Resources.Designer.cs + + + @@ -54,4 +91,10 @@ SourceFiles="$(OutputPath)../$(PackageId).$(PackageVersion).nupkg" DestinationFolder="../../tools/nuget" /> + + + + <_Parameter1>$(AssemblyName).UnitTests + + 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; + +/// +/// An analyzer to ensure that WebAPI classes are configured correctly. +/// SAS010. Warning: Methods that are public, should return a or just any T, where T is either: +/// or or +/// +/// SAS011. Warning: These methods must have at least one parameter, and first parameter must be +/// , where +/// TResponse is same type as in the return value. +/// SAS012. Warning: The second parameter can only be a +/// SAS013. Warning: These methods must be decorated with a +/// 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 +/// +[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 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(context)) + { + return; + } + + var allMethods = classDeclarationSyntax.Members.Where(member => member is MethodDeclarationSyntax) + .Cast(); + var operations = new Dictionary(); + 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"; // 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(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(context); + if (requestType is null) + { + context.ReportDiagnostic(Sas011, methodDeclarationSyntax); + return true; + } + + if (parameters.Count == 2) + { + var secondParam = parameters[1]; + if (secondParam.IsNotType(context)) + { + context.ReportDiagnostic(Sas012, methodDeclarationSyntax); + return true; + } + } + + return false; + } + + private static void RequestTypesAreNotDuplicated(SyntaxNodeAnalysisContext context, + Dictionary 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 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 RouteSegments { get; private set; } = Enumerable.Empty(); + + 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 @@ - Reference\Infrastruture.WebApi.Interfaces\IWebApiService.cs + Reference\Infrastructure.WebApi.Interfaces\IWebApiService.cs - Reference\Infrastruture.WebApi.Interfaces\IWebResponse.cs + Reference\Infrastructure.WebApi.Interfaces\IWebResponse.cs - Reference\Infrastruture.WebApi.Interfaces\WebApiOperation.cs + Reference\Infrastructure.WebApi.Interfaces\WebApiOperation.cs - Reference\Infrastruture.WebApi.Interfaces\WebApiRouteAttribute.cs + Reference\Infrastructure.WebApi.Interfaces\WebApiRouteAttribute.cs 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 82e3f205a992b88d418ab046f824421a96ab2e97..46c0ea26a6bab4a3f03359bc6d1f6015116ef8db 100644 GIT binary patch delta 25742 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 M2Nfp1^M6wR2M?ze4*&oF delta 8814 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