feat: implement analyzer and code fixer for identifying and fixing when a sync flag is not propagated correctly.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
<PropertyGroup>
|
||||
<PackageProjectUrl>https://gitea.freeborn.cloud/Stevan/stevanfreeborn.asyncsyncflaganalyzer</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.freeborn.cloud/Stevan/stevanfreeborn.asyncsyncflaganalyzer</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.SourceLink.Gitea">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" PrivateAssets="all" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" PrivateAssets="all" />
|
||||
<PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.300" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StevanFreeborn.AsyncSyncFlagAnalyzer.Common\StevanFreeborn.AsyncSyncFlagAnalyzer.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Composition;
|
||||
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CodeActions;
|
||||
using Microsoft.CodeAnalysis.CodeFixes;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
using StevanFreeborn.AsyncSyncFlagAnalyzer.Common;
|
||||
|
||||
namespace StevanFreeborn.AsyncSyncFlagAnalyzer.CodeFixes;
|
||||
|
||||
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(SyncParameterCodeFixProvider)), Shared]
|
||||
public class SyncParameterCodeFixProvider : CodeFixProvider
|
||||
{
|
||||
public sealed override ImmutableArray<string> FixableDiagnosticIds => [DiagnosticProperties.DiagnosticId];
|
||||
|
||||
public sealed override FixAllProvider GetFixAllProvider()
|
||||
{
|
||||
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.FirstOrDefault();
|
||||
|
||||
if (diagnostic is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var diagnosticSpan = diagnostic.Location.SourceSpan;
|
||||
|
||||
var invocation = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf()
|
||||
.OfType<InvocationExpressionSyntax>()
|
||||
.FirstOrDefault();
|
||||
|
||||
if (invocation is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = diagnostic.Properties.TryGetValue(DiagnosticProperties.EnclosingSyncName, out var enclosingSyncName);
|
||||
|
||||
if (string.IsNullOrEmpty(enclosingSyncName))
|
||||
{
|
||||
enclosingSyncName = DiagnosticProperties.DefaultSyncName;
|
||||
}
|
||||
|
||||
_ = diagnostic.Properties.TryGetValue(DiagnosticProperties.TargetSyncName, out var targetSyncName);
|
||||
|
||||
if (string.IsNullOrEmpty(targetSyncName))
|
||||
{
|
||||
targetSyncName = enclosingSyncName;
|
||||
}
|
||||
|
||||
_ = diagnostic.Properties.TryGetValue(DiagnosticProperties.TargetSyncOrdinal, out var targetSyncOrdinalStr);
|
||||
|
||||
if (!int.TryParse(targetSyncOrdinalStr, out var targetSyncOrdinal))
|
||||
{
|
||||
targetSyncOrdinal = -1;
|
||||
}
|
||||
|
||||
var codeAction = CodeAction.Create(
|
||||
title: $"Pass '{enclosingSyncName}' parameter",
|
||||
createChangedDocument: c => AddSyncParameterAsync(
|
||||
context.Document,
|
||||
invocation,
|
||||
enclosingSyncName!,
|
||||
targetSyncName!,
|
||||
targetSyncOrdinal,
|
||||
c
|
||||
),
|
||||
equivalenceKey: "PassSyncParameterFix"
|
||||
);
|
||||
|
||||
context.RegisterCodeFix(codeAction, diagnostic);
|
||||
}
|
||||
|
||||
private static async Task<Document> AddSyncParameterAsync(
|
||||
Document document,
|
||||
InvocationExpressionSyntax invocation,
|
||||
string enclosingSyncName,
|
||||
string targetSyncName,
|
||||
int targetSyncOrdinal,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (root is null)
|
||||
{
|
||||
return document;
|
||||
}
|
||||
|
||||
var arguments = invocation.ArgumentList.Arguments;
|
||||
var syncIdentifier = SyntaxFactory.IdentifierName(enclosingSyncName);
|
||||
|
||||
var existingNamedArg = arguments.FirstOrDefault(
|
||||
a => a.NameColon?.Name.Identifier.Text == targetSyncName
|
||||
);
|
||||
|
||||
if (existingNamedArg is not null)
|
||||
{
|
||||
var newNamedArg = existingNamedArg.WithExpression(syncIdentifier);
|
||||
var newArguments = arguments.Replace(existingNamedArg, newNamedArg);
|
||||
var newInvocation = invocation.WithArgumentList(invocation.ArgumentList.WithArguments(newArguments));
|
||||
var newRoot = root.ReplaceNode(invocation, newInvocation);
|
||||
return document.WithSyntaxRoot(newRoot);
|
||||
}
|
||||
|
||||
var positionalCount = 0;
|
||||
|
||||
foreach (var arg in arguments)
|
||||
{
|
||||
if (arg.NameColon is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
positionalCount++;
|
||||
}
|
||||
|
||||
var newArg = SyntaxFactory.Argument(syncIdentifier);
|
||||
var updatedArguments = targetSyncOrdinal >= positionalCount
|
||||
? arguments.Insert(positionalCount, newArg)
|
||||
: arguments.Replace(arguments[targetSyncOrdinal], newArg);
|
||||
|
||||
var resultInvocation = invocation.WithArgumentList(invocation.ArgumentList.WithArguments(updatedArguments));
|
||||
var resultRoot = root.ReplaceNode(invocation, resultInvocation);
|
||||
return document.WithSyntaxRoot(resultRoot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace StevanFreeborn.AsyncSyncFlagAnalyzer.Common;
|
||||
|
||||
public static class DiagnosticProperties
|
||||
{
|
||||
public const string DiagnosticId = "SYNC001";
|
||||
public const string DefaultSyncName = "sync";
|
||||
public const string EditorConfigKey = "dotnet_diagnostic.SYNC001.additional_sync_names";
|
||||
public const string EnclosingSyncName = "EnclosingSyncName";
|
||||
public const string TargetSyncName = "TargetSyncName";
|
||||
public const string TargetSyncOrdinal = "TargetSyncOrdinal";
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
## Release 0.0
|
||||
|
||||
### New Rules
|
||||
|
||||
Rule ID | Category | Severity | Notes
|
||||
--------|--------------|----------|----------------------
|
||||
SYNC001 | Architecture | Error | SyncParameterAnalyzer
|
||||
@@ -0,0 +1,52 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>StevanFreeborn.AsyncSyncFlagAnalyzer</PackageId>
|
||||
<Version>0.0.0</Version>
|
||||
<Authors>Stevan Freeborn</Authors>
|
||||
<Description>Roslyn analyzer to enforce passing the sync flag in optionally asynchronous execution paths.</Description>
|
||||
<PackageTags>analyzer,async,sync,flag</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
|
||||
<DevelopmentDependency>true</DevelopmentDependency>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<NoPackageAnalysis>true</NoPackageAnalysis>
|
||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(GITEA_ACTIONS)' == 'true'">
|
||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\StevanFreeborn.AsyncSyncFlagAnalyzer.CodeFixes\StevanFreeborn.AsyncSyncFlagAnalyzer.CodeFixes.csproj" ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\StevanFreeborn.AsyncSyncFlagAnalyzer.Common\StevanFreeborn.AsyncSyncFlagAnalyzer.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="IncludeCodeFixInPackage" BeforeTargets="GenerateNuspec" DependsOnTargets="Build">
|
||||
<ItemGroup>
|
||||
<_PackageFiles Include="$(MSBuildProjectDirectory)\..\$(MSBuildProjectName).CodeFixes\bin\$(Configuration)\$(TargetFramework)\$(MSBuildProjectName).CodeFixes.dll">
|
||||
<PackagePath>analyzers/dotnet/cs</PackagePath>
|
||||
<Visible>false</Visible>
|
||||
<BuildAction>None</BuildAction>
|
||||
</_PackageFiles>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
|
||||
using StevanFreeborn.AsyncSyncFlagAnalyzer.Common;
|
||||
|
||||
namespace StevanFreeborn.AsyncSyncFlagAnalyzer;
|
||||
|
||||
[DiagnosticAnalyzer(LanguageNames.CSharp)]
|
||||
public class SyncParameterAnalyzer : DiagnosticAnalyzer
|
||||
{
|
||||
private const string Title = "Missing sync parameter in optionally async call";
|
||||
private const string MessageFormat = "The method '{0}' must pass the '{1}' parameter to '{2}'";
|
||||
|
||||
private static readonly DiagnosticDescriptor Rule = new(
|
||||
DiagnosticProperties.DiagnosticId,
|
||||
Title,
|
||||
MessageFormat,
|
||||
"Architecture",
|
||||
DiagnosticSeverity.Error,
|
||||
isEnabledByDefault: true
|
||||
);
|
||||
|
||||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];
|
||||
|
||||
public override void Initialize(AnalysisContext context)
|
||||
{
|
||||
if (context is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
|
||||
context.EnableConcurrentExecution();
|
||||
context.RegisterSyntaxNodeAction(AnalyzeAwaitExpression, SyntaxKind.AwaitExpression);
|
||||
}
|
||||
|
||||
private void AnalyzeAwaitExpression(SyntaxNodeAnalysisContext context)
|
||||
{
|
||||
var awaitExpr = (AwaitExpressionSyntax)context.Node;
|
||||
|
||||
var enclosingMethod = awaitExpr.FirstAncestorOrSelf<MethodDeclarationSyntax>();
|
||||
|
||||
if (enclosingMethod is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var validSyncNames = GetValidSyncNames(context);
|
||||
|
||||
var enclosingSyncParam = enclosingMethod.ParameterList.Parameters
|
||||
.FirstOrDefault(p => validSyncNames.Contains(p.Identifier.Text));
|
||||
|
||||
if (enclosingSyncParam is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var expectedSyncName = enclosingSyncParam.Identifier.Text;
|
||||
|
||||
if (awaitExpr.Expression is not InvocationExpressionSyntax invocation)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (context.SemanticModel.GetSymbolInfo(invocation).Symbol is not IMethodSymbol methodSymbol)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var targetSyncParam = methodSymbol.Parameters.FirstOrDefault(p => validSyncNames.Contains(p.Name));
|
||||
|
||||
if (targetSyncParam is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var passedSync = false;
|
||||
|
||||
foreach (var argument in invocation.ArgumentList.Arguments)
|
||||
{
|
||||
if (argument.Expression is IdentifierNameSyntax id && id.Identifier.Text == expectedSyncName)
|
||||
{
|
||||
passedSync = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!passedSync)
|
||||
{
|
||||
var properties = ImmutableDictionary<string, string?>.Empty
|
||||
.Add(DiagnosticProperties.EnclosingSyncName, enclosingSyncParam.Identifier.Text)
|
||||
.Add(DiagnosticProperties.TargetSyncName, targetSyncParam.Name)
|
||||
.Add(DiagnosticProperties.TargetSyncOrdinal, targetSyncParam.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
var diagnostic = Diagnostic.Create(
|
||||
Rule,
|
||||
invocation.GetLocation(),
|
||||
properties,
|
||||
enclosingMethod.Identifier.Text,
|
||||
expectedSyncName,
|
||||
methodSymbol.Name
|
||||
);
|
||||
|
||||
context.ReportDiagnostic(diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
private static HashSet<string> GetValidSyncNames(SyntaxNodeAnalysisContext context)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { DiagnosticProperties.DefaultSyncName };
|
||||
var options = context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree);
|
||||
|
||||
if (options.TryGetValue(DiagnosticProperties.EditorConfigKey, out var customNames) && !string.IsNullOrWhiteSpace(customNames))
|
||||
{
|
||||
var splitNames = customNames.Split([','], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var name in splitNames)
|
||||
{
|
||||
_ = names.Add(name.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user