Skip to content

Commit

Permalink
Feature/add transform result refactoring (#42)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Duke <commits@dmail.icu>
Co-authored-by: Stuart Turner <stuart@turner-isageek.com>
  • Loading branch information
3 people authored Apr 21, 2024
1 parent 92bf73c commit 9a0621a
Show file tree
Hide file tree
Showing 8 changed files with 442 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/Immediate.Apis.Analyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ IAPI0003 | ImmediateApis | Warning | InvalidAuthorizeAttributeAnalyzer
IAPI0004 | ImmediateApis | Warning | CustomizeEndpointUsageAnalyzer
IAPI0005 | ImmediateApis | Warning | TransformResultUsageAnalyzer
IAPI0006 | ImmediateApis | Hidden | MissingCustomizeEndpointMethodAnalyzer
IAPI0007 | ImmediateApis | Hidden | MissingTransformResultMethodAnalyzer
1 change: 1 addition & 0 deletions src/Immediate.Apis.Analyzers/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ internal static class DiagnosticIds
public const string IAPI0004CustomizeEndpointInvalid = "IAPI0004";
public const string IAPI0005TransformResultInvalid = "IAPI0005";
public const string IAPI0006MissingCustomizeEndpointMethod = "IAPI0006";
public const string IAPI0007MissingTransformResultMethod = "IAPI0007";
}
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()
)
);
}
}
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;
}
}
44 changes: 44 additions & 0 deletions tests/AspNetCore.Ref/Results.cs
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
{
}
13 changes: 13 additions & 0 deletions tests/AspNetCore.Ref/TypedResults.cs
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>();
}
}
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();
}
}
Loading

0 comments on commit 9a0621a

Please sign in to comment.