-
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feature/add transform result refactoring (#42)
--------- Co-authored-by: Duke <commits@dmail.icu> Co-authored-by: Stuart Turner <stuart@turner-isageek.com>
- Loading branch information
1 parent
92bf73c
commit 9a0621a
Showing
8 changed files
with
442 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
87 changes: 87 additions & 0 deletions
87
src/Immediate.Apis.Analyzers/MissingTransformResultMethodAnalyzer.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
using System.Collections.Immutable; | ||
using Microsoft.CodeAnalysis; | ||
using Microsoft.CodeAnalysis.CSharp.Syntax; | ||
using Microsoft.CodeAnalysis.Diagnostics; | ||
|
||
namespace Immediate.Apis.Analyzers; | ||
|
||
[DiagnosticAnalyzer(LanguageNames.CSharp)] | ||
public sealed class MissingTransformResultMethodAnalyzer : DiagnosticAnalyzer | ||
{ | ||
public static readonly DiagnosticDescriptor MissingTransformResultMethod = | ||
new( | ||
id: DiagnosticIds.IAPI0007MissingTransformResultMethod, | ||
title: "Missing `TransformResult` method", | ||
messageFormat: "Missing `TransformResult` method in the class", | ||
category: "ImmediateApis", | ||
defaultSeverity: DiagnosticSeverity.Hidden, | ||
isEnabledByDefault: true, | ||
description: "A class with `MapMethod` attribute can have a `TransformResult` method." | ||
); | ||
|
||
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = | ||
ImmutableArray.Create( | ||
[ | ||
MissingTransformResultMethod, | ||
]); | ||
|
||
public override void Initialize(AnalysisContext context) | ||
{ | ||
if (context == null) | ||
throw new ArgumentNullException(nameof(context)); | ||
|
||
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); | ||
context.EnableConcurrentExecution(); | ||
|
||
context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType); | ||
} | ||
|
||
private static void AnalyzeSymbol(SymbolAnalysisContext context) | ||
{ | ||
var token = context.CancellationToken; | ||
token.ThrowIfCancellationRequested(); | ||
|
||
if (context.Symbol is not INamedTypeSymbol namedTypeSymbol) | ||
return; | ||
|
||
if (!namedTypeSymbol | ||
.GetAttributes() | ||
.Any(x => x.AttributeClass.IsMapMethodAttribute())) | ||
{ | ||
return; | ||
} | ||
|
||
token.ThrowIfCancellationRequested(); | ||
|
||
if (!namedTypeSymbol | ||
.GetMembers() | ||
.OfType<IMethodSymbol>() | ||
.Any(ims => ims.Name is "Handle" or "HandleAsync")) | ||
{ | ||
return; | ||
} | ||
|
||
if (namedTypeSymbol | ||
.GetMembers() | ||
.OfType<IMethodSymbol>() | ||
.Any(ims => ims.Name is "TransformResult")) | ||
{ | ||
return; | ||
} | ||
|
||
token.ThrowIfCancellationRequested(); | ||
|
||
var syntax = (ClassDeclarationSyntax)namedTypeSymbol | ||
.DeclaringSyntaxReferences[0] | ||
.GetSyntax(); | ||
|
||
context.ReportDiagnostic( | ||
Diagnostic.Create( | ||
MissingTransformResultMethod, | ||
syntax | ||
.Identifier | ||
.GetLocation() | ||
) | ||
); | ||
} | ||
} |
121 changes: 121 additions & 0 deletions
121
src/Immediate.Apis.CodeFixes/MissingTransformResultMethodCodeFixProvider.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
using System.Collections.Immutable; | ||
using Immediate.Apis.Analyzers; | ||
using Microsoft.CodeAnalysis; | ||
using Microsoft.CodeAnalysis.CodeActions; | ||
using Microsoft.CodeAnalysis.CodeFixes; | ||
using Microsoft.CodeAnalysis.CSharp; | ||
using Microsoft.CodeAnalysis.CSharp.Syntax; | ||
using Microsoft.CodeAnalysis.Formatting; | ||
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; | ||
|
||
namespace Immediate.Apis.CodeFixes; | ||
|
||
[ExportCodeFixProvider(LanguageNames.CSharp)] | ||
public class MissingTransformResultMethodCodeFixProvider : CodeFixProvider | ||
{ | ||
public sealed override ImmutableArray<string> FixableDiagnosticIds { get; } = | ||
ImmutableArray.Create([DiagnosticIds.IAPI0007MissingTransformResultMethod]); | ||
|
||
public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; | ||
|
||
public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context) | ||
{ | ||
// We link only one diagnostic and assume there is only one diagnostic in the context. | ||
var diagnostic = context.Diagnostics.Single(); | ||
|
||
// 'SourceSpan' of 'Location' is the highlighted area. We're going to use this area to find the 'SyntaxNode' to rename. | ||
var diagnosticSpan = diagnostic.Location.SourceSpan; | ||
|
||
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); | ||
|
||
if (root?.FindNode(diagnosticSpan) is ClassDeclarationSyntax classDeclarationSyntax | ||
&& root is CompilationUnitSyntax compilationUnitSyntax) | ||
{ | ||
context.RegisterCodeFix( | ||
CodeAction.Create( | ||
title: "Add `TransformResult` method", | ||
createChangedDocument: c => | ||
AddTransformResultMethodAsync(context.Document, compilationUnitSyntax, classDeclarationSyntax, c), | ||
equivalenceKey: nameof(MissingCustomizeEndpointMethodCodeFixProvider) | ||
), | ||
diagnostic); | ||
} | ||
} | ||
|
||
private static async Task<Document> AddTransformResultMethodAsync( | ||
Document document, | ||
CompilationUnitSyntax root, | ||
ClassDeclarationSyntax classDeclarationSyntax, | ||
CancellationToken cancellationToken | ||
) | ||
{ | ||
var model = await document.GetSemanticModelAsync(cancellationToken) ?? throw new InvalidOperationException("Could not get semantic model"); | ||
|
||
if (model.GetDeclaredSymbol(classDeclarationSyntax, cancellationToken: cancellationToken) is not { } handlerClassSymbol) | ||
return document; | ||
|
||
var handleMethodSymbol = handlerClassSymbol | ||
.GetMembers() | ||
.OfType<IMethodSymbol>() | ||
.FirstOrDefault(x => x.Name == "Handle"); | ||
|
||
if (handleMethodSymbol is null) | ||
return document; | ||
|
||
if (await handleMethodSymbol.DeclaringSyntaxReferences[0] | ||
.GetSyntaxAsync(cancellationToken) | ||
is not MethodDeclarationSyntax handleMethodSyntax) | ||
{ | ||
return document; | ||
} | ||
|
||
if (handleMethodSyntax.ReturnType is not GenericNameSyntax | ||
{ | ||
TypeArgumentList.Arguments: [{ } returnType] | ||
}) | ||
{ | ||
return document; | ||
} | ||
|
||
var transformResultMethodSyntax = MethodDeclaration( | ||
returnType, | ||
Identifier("TransformResult")) | ||
.WithModifiers( | ||
TokenList( | ||
[ | ||
Token(SyntaxKind.InternalKeyword), | ||
Token(SyntaxKind.StaticKeyword), | ||
])) | ||
.WithParameterList( | ||
ParameterList( | ||
SingletonSeparatedList( | ||
Parameter( | ||
Identifier("result")) | ||
.WithType(returnType)))) | ||
.WithBody( | ||
Block( | ||
SingletonList<StatementSyntax>( | ||
ReturnStatement( | ||
IdentifierName("result"))))) | ||
.WithAdditionalAnnotations(Formatter.Annotation); | ||
|
||
// Manually add trailing trivia to ensure proper spacing | ||
var newMembers = classDeclarationSyntax.Members | ||
.Insert( | ||
0, | ||
transformResultMethodSyntax | ||
); | ||
|
||
var newClassDecl = classDeclarationSyntax | ||
.WithMembers(newMembers); | ||
|
||
// Replace the old class declaration with the new one | ||
var newRoot = root.ReplaceNode(classDeclarationSyntax, newClassDecl); | ||
|
||
// Create a new document with the updated syntax root | ||
var newDocument = document.WithSyntaxRoot(newRoot); | ||
|
||
// Return the new document | ||
return newDocument; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
using System.Diagnostics.CodeAnalysis; | ||
|
||
#nullable disable | ||
namespace Microsoft.AspNetCore.Http.HttpResults; | ||
|
||
#pragma warning disable CA1040 | ||
public interface IResult; | ||
#pragma warning restore CA1040 | ||
|
||
[ExcludeFromCodeCoverage] | ||
public sealed class Results<TResult1, TResult2> | ||
: IResult | ||
where TResult1 : IResult | ||
where TResult2 : IResult | ||
{ | ||
private Results(IResult activeResult) => this.Result = activeResult; | ||
|
||
public IResult Result { get; } | ||
|
||
public static implicit operator Results<TResult1, TResult2>(TResult1 result) | ||
{ | ||
return new Results<TResult1, TResult2>(result); | ||
} | ||
|
||
public static implicit operator Results<TResult1, TResult2>(TResult2 result) | ||
{ | ||
return new Results<TResult1, TResult2>(result); | ||
} | ||
|
||
public Results<TResult1, TResult2> ToResults() | ||
{ | ||
throw new NotImplementedException(); | ||
} | ||
} | ||
|
||
[ExcludeFromCodeCoverage] | ||
public sealed class Ok<TValue> : IResult | ||
{ | ||
} | ||
|
||
[ExcludeFromCodeCoverage] | ||
public sealed class NotFound : IResult | ||
{ | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
using System.Diagnostics.CodeAnalysis; | ||
using Microsoft.AspNetCore.Http.HttpResults; | ||
|
||
namespace Microsoft.AspNetCore.Http; | ||
|
||
[ExcludeFromCodeCoverage] | ||
public static class TypedResults | ||
{ | ||
public static Ok<TValue> Ok<TValue>(TValue result) | ||
{ | ||
return new Ok<TValue>(); | ||
} | ||
} |
104 changes: 104 additions & 0 deletions
104
tests/Immediate.Apis.Tests/AnalyzerTests/MissingTransformResultMethodAnalyzerTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
using System.Diagnostics.CodeAnalysis; | ||
using Immediate.Apis.Analyzers; | ||
|
||
namespace Immediate.Apis.Tests.AnalyzerTests; | ||
|
||
// NB: CS0234 is due to lack of `Microsoft.AspNetCore.Http.Abstractions` library | ||
// TODO: figure out how to reference `Microsoft.AspNetCore.App.Ref` | ||
|
||
[SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Test names")] | ||
public sealed class MissingTransformResultMethodAnalyzerTests | ||
{ | ||
[Theory] | ||
[MemberData(nameof(Utility.Methods), MemberType = typeof(Utility))] | ||
public async Task ValidDefinitionShouldRaiseHiddenDiagnostic(string method) | ||
{ | ||
await AnalyzerTestHelpers.CreateAnalyzerTest<MissingTransformResultMethodAnalyzer>( | ||
$$""" | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Immediate.Apis.Shared; | ||
using Immediate.Handlers.Shared; | ||
using Microsoft.AspNetCore.Authorization; | ||
namespace Dummy; | ||
[Handler] | ||
[Map{{method}}("/test")] | ||
public static class {|IAPI0007:GetUsersQuery|} | ||
{ | ||
public record Query; | ||
private static async ValueTask<int> Handle( | ||
Query _, | ||
CancellationToken token) | ||
{ | ||
return 0; | ||
} | ||
} | ||
""" | ||
).RunAsync(); | ||
} | ||
|
||
[Theory] | ||
[MemberData(nameof(Utility.Methods), MemberType = typeof(Utility))] | ||
public async Task ValidDefinition_WithExistingTransformResultMethod_ShouldNotRaiseHiddenDiagnostic(string method) | ||
{ | ||
await AnalyzerTestHelpers.CreateAnalyzerTest<MissingTransformResultMethodAnalyzer>( | ||
$$""" | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Immediate.Apis.Shared; | ||
using Immediate.Handlers.Shared; | ||
using Microsoft.AspNetCore.Http.HttpResults; | ||
using Microsoft.AspNetCore.Http; | ||
namespace Dummy; | ||
[Handler] | ||
[Map{{method}}("/test")] | ||
public static class GetUsersQuery | ||
{ | ||
internal static Results<Ok<int>, NotFound> TransformResult(int result) | ||
{ | ||
return TypedResults.Ok(result); | ||
} | ||
public record Query; | ||
private static async ValueTask<int> Handle( | ||
Query _, | ||
CancellationToken token) | ||
{ | ||
return 0; | ||
} | ||
} | ||
""" | ||
).RunAsync(); | ||
} | ||
|
||
[Theory] | ||
[MemberData(nameof(Utility.Methods), MemberType = typeof(Utility))] | ||
public async Task InvalidDefinition_ShouldNotRaiseHiddenDiagnostic(string method) | ||
{ | ||
await AnalyzerTestHelpers.CreateAnalyzerTest<MissingTransformResultMethodAnalyzer>( | ||
$$""" | ||
using System.Threading; | ||
using System.Threading.Tasks; | ||
using Immediate.Apis.Shared; | ||
using Immediate.Handlers.Shared; | ||
using Microsoft.AspNetCore.Http.HttpResults; | ||
using Microsoft.AspNetCore.Http; | ||
namespace Dummy; | ||
[Handler] | ||
[Map{{method}}("/test")] | ||
public static class GetUsersQuery | ||
{ | ||
public record Query; | ||
} | ||
""" | ||
).RunAsync(); | ||
} | ||
} |
Oops, something went wrong.