Skip to content

Commit

Permalink
Add Code Analyzer one method per endpoint (ardalis#25)
Browse files Browse the repository at this point in the history
* initial code analyzer template

* EndpointHasExtraPublicMethodAnalyzer
  • Loading branch information
ppittle authored Aug 7, 2020
1 parent e4695d7 commit 77a8082
Show file tree
Hide file tree
Showing 16 changed files with 1,516 additions and 4 deletions.
16 changes: 16 additions & 0 deletions Ardalis.ApiEndpoints.sln
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_solutionItems", "_solution
LICENSE.txt = LICENSE.txt
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{1241985D-5277-49C6-9570-6E1FA1851CBA}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.ApiEndpoints.CodeAnalyzers.Test", "tests\Ardalis.ApiEndpoints.CodeAnalyzers.Test\Ardalis.ApiEndpoints.CodeAnalyzers.Test.csproj", "{EF801BF2-AA72-4FCF-AA67-09B5E3ED4283}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.ApiEndpoints.CodeAnalyzers", "src\Ardalis.ApiEndpoints.CodeAnalyzers\Ardalis.ApiEndpoints.CodeAnalyzers.csproj", "{7D8E74B0-4620-492A-940A-FCEEF9CF92F9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -36,6 +42,14 @@ Global
{B7EB2D30-B907-4B61-96F6-091F90590438}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B7EB2D30-B907-4B61-96F6-091F90590438}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B7EB2D30-B907-4B61-96F6-091F90590438}.Release|Any CPU.Build.0 = Release|Any CPU
{EF801BF2-AA72-4FCF-AA67-09B5E3ED4283}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EF801BF2-AA72-4FCF-AA67-09B5E3ED4283}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EF801BF2-AA72-4FCF-AA67-09B5E3ED4283}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EF801BF2-AA72-4FCF-AA67-09B5E3ED4283}.Release|Any CPU.Build.0 = Release|Any CPU
{7D8E74B0-4620-492A-940A-FCEEF9CF92F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D8E74B0-4620-492A-940A-FCEEF9CF92F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D8E74B0-4620-492A-940A-FCEEF9CF92F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D8E74B0-4620-492A-940A-FCEEF9CF92F9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -44,6 +58,8 @@ Global
{6C623E88-4737-417C-B835-FA1202F98866} = {CE4A1E99-D876-4D8F-B56E-9BF453456BAB}
{851683FE-B589-43DB-93B5-F38C7AAEBEE2} = {38434103-76E0-4820-B4AF-F5EA5D08A7BD}
{B7EB2D30-B907-4B61-96F6-091F90590438} = {38434103-76E0-4820-B4AF-F5EA5D08A7BD}
{EF801BF2-AA72-4FCF-AA67-09B5E3ED4283} = {1241985D-5277-49C6-9570-6E1FA1851CBA}
{7D8E74B0-4620-492A-940A-FCEEF9CF92F9} = {CE4A1E99-D876-4D8F-B56E-9BF453456BAB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {932AEB26-7B36-4EFB-B4D1-41F13EDAE5D2}
Expand Down
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,6 @@ Technically, yes. But **don't do that**. If you really want that, you should jus

The following are some things I'd like to add to the project/package.

### Roslyn Analyzer

An analyzer could detect if more than one public method were added to a Handler, for instance.

### Item Template

Visual Studio and/or CLI item templates would make it much easier to create Endpoints and their associated models, with the correct naming so they're linked in the IDE.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
</PropertyGroup>

<PropertyGroup>
<PackageId>Ardalis.ApiEndpoints.CodeAnalyzers</PackageId>
<Version>1.0.0</Version>
<Authors>Steve Smith (@ardalis), Philip Pittle (@ppittle)</Authors>
<PackageIconUrl>https://user-images.githubusercontent.com/782127/33497760-facf6550-d69c-11e7-94e4-b3856da259a9.png</PackageIconUrl>
<Company>Ardalis.com</Company>
<PackageProjectUrl>https://github.com/ardalis/ApiEndpoints</PackageProjectUrl>
<Description>Code Analyzers supporting using Api Endpoints</Description>
<Summary>Code Analyzers increasing productivity of developers using the Api Endpoints framework.</Summary>
<RepositoryUrl>https://github.com/ardalis/ApiEndpoints</RepositoryUrl>
<NoPackageAnalysis>true</NoPackageAnalysis>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="2.9.8" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.3.1" />
</ItemGroup>

<ItemGroup>
<None Update="tools\*.ps1" CopyToOutputDirectory="Always" Pack="true" PackagePath="" />
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Ardalis.ApiEndpoints.CodeAnalyzers
{
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class EndpointHasExtraPublicMethodAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "ApiEndpoints101";

internal static readonly LocalizableString Title = "Endpoint has more than one public method";
internal static readonly LocalizableString MessageFormat = "Endpoint {0} has additional public method {1}. Endpoints must have only one public method.";
private static readonly LocalizableString Description = "MVC will interpret additional public methods on an Endpoint as Actions. Limit Endpoints to a single Action";
private const string Category = "Naming";

private static readonly DiagnosticDescriptor Rule =
new DiagnosticDescriptor(
DiagnosticId,
Title,
MessageFormat,
Category,
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: Description);

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);

public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSymbolAction(AnalyzeMethodDeclaration, SymbolKind.Method);
}

private void AnalyzeMethodDeclaration(SymbolAnalysisContext context)
{
try
{
var methodSymbol = context.Symbol as IMethodSymbol;

if (null == methodSymbol)
return;

var isApiEndpoint =
methodSymbol
.ContainingType
.GetAllBaseTypes()
.Any(x =>
x.Name == "BaseEndpoint" ||
x.Name == "BaseAsyncEndpoint");

// not a type inheriting BaseEndpoint
if (!isApiEndpoint)
return;

// isn't a new public method
if (methodSymbol.IsOverride || methodSymbol.DeclaredAccessibility != Accessibility.Public)
return;

// at this point, we have a new public method on a BaseEndpoint that violates the rule
var diagnostic = Diagnostic.Create(
Rule,
context.Symbol.Locations.FirstOrDefault(),
methodSymbol.ContainingType.Name,
methodSymbol.Name);

context.ReportDiagnostic(diagnostic);
}
catch (Exception e)
{
Debug.Write(e);

if (Debugger.IsAttached)
throw;
}
}
}

public static class NamedTypeSymbolExtensions
{
public static List<INamedTypeSymbol> GetAllBaseTypes(this INamedTypeSymbol namedTypeSymbol)
{
var baseTypes = new List<INamedTypeSymbol>();

for (var visitor = namedTypeSymbol.BaseType; visitor != null; visitor = visitor.BaseType)
{
if (!baseTypes.Contains(visitor))
baseTypes.Add(visitor);
}

return baseTypes;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System;
using System.Collections.Immutable;
using System.Composition;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Ardalis.ApiEndpoints.CodeAnalyzers
{
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(EndpointHasExtraPublicMethodCodeFixProvider)), Shared]
public class EndpointHasExtraPublicMethodCodeFixProvider : CodeFixProvider
{
private const string MakeInternalTitle = "Make additonal method internal.";
private const string MakePrivateTitle = "Make additonal method private.";

public sealed override ImmutableArray<string> FixableDiagnosticIds
{
get { return ImmutableArray.Create(EndpointHasExtraPublicMethodAnalyzer.DiagnosticId); }
}

public sealed override FixAllProvider GetFixAllProvider()
{
// See https://github.com/dotnet/roslyn/blob/master/docs/analyzers/FixAllProvider.md for more information on Fix All Providers
return WellKnownFixAllProviders.BatchFixer;
}

public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);

var diagnostic = context.Diagnostics.First();
var diagnosticSpan = diagnostic.Location.SourceSpan;

// Find the method declaration identified by the diagnostic.
var declaration =
root
.FindToken(diagnosticSpan.Start)
.Parent
.AncestorsAndSelf()
.OfType<MethodDeclarationSyntax>()
.First();

// Register a code action that will invoke the fix.
context.RegisterCodeFix(
CodeAction.Create(
title: MakeInternalTitle,
createChangedDocument: c => ChangeMethodKindAsync(context.Document, declaration, SyntaxKind.InternalKeyword, c),
equivalenceKey: MakeInternalTitle),
diagnostic);

context.RegisterCodeFix(
CodeAction.Create(
title: MakePrivateTitle,
createChangedDocument: c => ChangeMethodKindAsync(context.Document, declaration, SyntaxKind.PrivateKeyword, c),
equivalenceKey: MakePrivateTitle),
diagnostic);
}

private async Task<Document> ChangeMethodKindAsync(
Document document,
MethodDeclarationSyntax method,
SyntaxKind targetKind,
CancellationToken cancellationToken)
{
try
{
var modifierList =
// create the new modifier list, but replace the public token
// goal is to preserve order
method
.Modifiers
.Select(x =>
x.Kind() is SyntaxKind.PublicKeyword
? SyntaxFactory.Token(targetKind)
: x)
.ToList();

// remove the public modifier
var newMethod = method.WithModifiers(new SyntaxTokenList(modifierList));

var tree = await document.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);
var root = await tree.GetRootAsync(cancellationToken).ConfigureAwait(false);

var newRoot = root.ReplaceNode(method, newMethod);

return document.WithSyntaxRoot(newRoot);
}
catch (Exception e)
{
Debug.Write(e);

if (Debugger.IsAttached)
throw;

return document;
}
}
}
}
58 changes: 58 additions & 0 deletions src/Ardalis.ApiEndpoints.CodeAnalyzers/tools/install.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
param($installPath, $toolsPath, $package, $project)

if($project.Object.SupportsPackageDependencyResolution)
{
if($project.Object.SupportsPackageDependencyResolution())
{
# Do not install analyzers via install.ps1, instead let the project system handle it.
return
}
}

$analyzersPaths = Join-Path (Join-Path (Split-Path -Path $toolsPath -Parent) "analyzers") * -Resolve

foreach($analyzersPath in $analyzersPaths)
{
if (Test-Path $analyzersPath)
{
# Install the language agnostic analyzers.
foreach ($analyzerFilePath in Get-ChildItem -Path "$analyzersPath\*.dll" -Exclude *.resources.dll)
{
if($project.Object.AnalyzerReferences)
{
$project.Object.AnalyzerReferences.Add($analyzerFilePath.FullName)
}
}
}
}

# $project.Type gives the language name like (C# or VB.NET)
$languageFolder = ""
if($project.Type -eq "C#")
{
$languageFolder = "cs"
}
if($project.Type -eq "VB.NET")
{
$languageFolder = "vb"
}
if($languageFolder -eq "")
{
return
}

foreach($analyzersPath in $analyzersPaths)
{
# Install language specific analyzers.
$languageAnalyzersPath = join-path $analyzersPath $languageFolder
if (Test-Path $languageAnalyzersPath)
{
foreach ($analyzerFilePath in Get-ChildItem -Path "$languageAnalyzersPath\*.dll" -Exclude *.resources.dll)
{
if($project.Object.AnalyzerReferences)
{
$project.Object.AnalyzerReferences.Add($analyzerFilePath.FullName)
}
}
}
}
Loading

0 comments on commit 77a8082

Please sign in to comment.