Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.8.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeRefactoring.Testing" Version="1.1.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.8.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.8.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.7" />
Expand Down
2 changes: 1 addition & 1 deletion src/Common/ITypeSymbolExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ typeSymbol is INamedTypeSymbol
},
};

public static bool IsCancellationToken(this ITypeSymbol typeSymbol) =>
public static bool IsCancellationToken(this ITypeSymbol? typeSymbol) =>
typeSymbol is INamedTypeSymbol
{
Name: "CancellationToken",
Expand Down
12 changes: 12 additions & 0 deletions src/Common/SyntaxExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Immediate.Handlers;

internal static class SyntaxExtensions
{
public static bool IsCancellationToken(this SemanticModel model, TypeSyntax? typeSyntax, CancellationToken token) =>
typeSyntax is { } syntax
&& model.GetSymbolInfo(syntax, token).Symbol is INamedTypeSymbol namedType
&& namedType.IsCancellationToken();
}
35 changes: 35 additions & 0 deletions src/Immediate.Handlers.CodeFixes/RefactoringExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeRefactorings;
using Microsoft.CodeAnalysis.Text;

namespace Immediate.Handlers.CodeFixes;

[ExcludeFromCodeCoverage]
internal static class RefactoringExtensions
{
internal static void Deconstruct(this CodeRefactoringContext context, out Document document, out TextSpan span, out CancellationToken cancellationToken)
{
document = context.Document;
span = context.Span;
cancellationToken = context.CancellationToken;
}

public static async ValueTask<SyntaxNode> GetRequiredSyntaxRootAsync(this Document document, CancellationToken cancellationToken)
{
if (document.TryGetSyntaxRoot(out var root))
return root;

return await document.GetSyntaxRootAsync(cancellationToken)
?? throw new InvalidOperationException($"Failed to retrieve the syntax root for document '{document.Name ?? document.FilePath ?? "unknown"}'.");
}

public static async ValueTask<SemanticModel> GetRequiredSemanticModelAsync(this Document document, CancellationToken cancellationToken)
{
if (document.TryGetSemanticModel(out var semanticModel))
return semanticModel;

return await document.GetSemanticModelAsync(cancellationToken)
?? throw new InvalidOperationException("Could not retrieve semantic model for the document.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeRefactorings;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;

namespace Immediate.Handlers.CodeFixes;

[ExportCodeRefactoringProvider(LanguageNames.CSharp, Name = "Convert to instance handler")]
public sealed class StaticToSealedHandlerRefactoringProvider : CodeRefactoringProvider
{
public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context)
{
var (document, span, token) = context;
token.ThrowIfCancellationRequested();

if (await document.GetRequiredSyntaxRootAsync(token) is not CompilationUnitSyntax root)
return;

var model = await document.GetRequiredSemanticModelAsync(token);

switch (root.FindNode(span))
{
case ClassDeclarationSyntax cds:
{
if (model.GetDeclaredSymbol(cds, token) is not INamedTypeSymbol { IsStatic: true } container)
return;

if (!container.GetAttributes().Any(a => a.AttributeClass.IsHandlerAttribute()))
return;

var method = container.GetMembers()
.OfType<IMethodSymbol>()
.FirstOrDefault(m => m is { IsStatic: true, Name: "Handle" or "HandleAsync" });

if (method is null)
return;

var mds = (MethodDeclarationSyntax)await method
.DeclaringSyntaxReferences[0]
.GetSyntaxAsync(token);

var service = new RefactoringService(
document,
model,
root,
cds,
mds
);

context.RegisterRefactoring(
CodeAction.Create(
title: "Convert to instance handler",
createChangedDocument: service.ConvertToInstanceHandler,
equivalenceKey: nameof(StaticToSealedHandlerRefactoringProvider)
)
);

break;
}

case MethodDeclarationSyntax mds:
{
if (model.GetDeclaredSymbol(mds, token) is not IMethodSymbol
{
IsStatic: true,
Name: "Handle" or "HandleAsync",
ContainingType: INamedTypeSymbol { IsStatic: true } container,
} method)
{
return;
}

if (!container.GetAttributes().Any(a => a.AttributeClass.IsHandlerAttribute()))
return;

var service = new RefactoringService(
document,
model,
root,
(ClassDeclarationSyntax)mds.Parent!,
mds
);

context.RegisterRefactoring(
CodeAction.Create(
title: "Convert to instance handler",
createChangedDocument: service.ConvertToInstanceHandler,
equivalenceKey: nameof(StaticToSealedHandlerRefactoringProvider)
)
);

break;
}

default:
break;
}
}

}

file sealed class RefactoringService(
Document document,
SemanticModel model,
CompilationUnitSyntax documentRoot,
ClassDeclarationSyntax classDeclarationSyntax,
MethodDeclarationSyntax methodDeclarationSyntax
)
{
public Task<Document> ConvertToInstanceHandler(
CancellationToken token
)
{
var methodParameters = methodDeclarationSyntax.ParameterList.Parameters;

var isLastParamCancellationToken = model.IsCancellationToken(methodParameters[^1].Type, token);

var classParameters = methodParameters
.Skip(1)
.Take(methodParameters.Count - (isLastParamCancellationToken ? 2 : 1))
.Select(p => p.WithTrailingTrivia(ElasticSpace))
.ToList();

var newMethodParameters = methodParameters.RemoveParametersUntilCount(isLastParamCancellationToken ? 2 : 1);

var newMethodDeclarationSyntax = methodDeclarationSyntax
.WithParameterList(
methodDeclarationSyntax.ParameterList
.WithParameters(newMethodParameters)
)
.WithModifiers(
methodDeclarationSyntax.Modifiers
.RemoveAll(static p => p.IsKind(SyntaxKind.StaticKeyword))
);

var newClassDeclarationSyntax = classDeclarationSyntax
.ReplaceNode(methodDeclarationSyntax, newMethodDeclarationSyntax)
.WithModifiers(
classDeclarationSyntax.Modifiers
.RemoveAll(static p => p.IsKind(SyntaxKind.StaticKeyword))
.Insert(
// valid case will have `partial` as final element; insert `sealed` before `partial`
classDeclarationSyntax.Modifiers.Count - 2,
Token(SyntaxKind.SealedKeyword).WithTrailingTrivia(ElasticSpace)
)
);

if (classParameters.Count > 0)
{
newClassDeclarationSyntax = newClassDeclarationSyntax
.WithParameterList(
ParameterList(SeparatedList(classParameters))
)
.WithIdentifier(classDeclarationSyntax.Identifier.WithoutTrivia());
}

return Task.FromResult(document.WithSyntaxRoot(documentRoot.ReplaceNode(classDeclarationSyntax, newClassDeclarationSyntax)));
}
}

file static class SyntaxExtensions
{
public static SeparatedSyntaxList<ParameterSyntax> RemoveParametersUntilCount(
this SeparatedSyntaxList<ParameterSyntax> nodes,
int count
)
{
while (nodes.Count > count)
nodes = nodes.RemoveAt(1);
return nodes;
}

public static SyntaxTokenList RemoveAll(
this SyntaxTokenList list,
Func<SyntaxToken, bool> filter
)
{
for (var i = 0; i < list.Count; i++)
{
if (filter(list[i]))
{
list = list.RemoveAt(i);
i--;
}
}

return list;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Diagnostics.CodeAnalysis;
using Immediate.Handlers.Tests.Helpers;
using Microsoft.CodeAnalysis.CodeRefactorings;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;

namespace Immediate.Handlers.Tests.CodeFixTests;

public static class CodeRefactoringTestHelper
{
private const string EditorConfig =
"""
root = true

[*.cs]
charset = utf-8
indent_style = tab
insert_final_newline = true
indent_size = 4
""";

public static CSharpCodeRefactoringTest<TRefactoring, DefaultVerifier> CreateCodeRefactoringTest<TRefactoring>(
[StringSyntax("c#-test")] string inputSource,
[StringSyntax("c#-test")] string fixedSource,
int codeActionIndex = 0
)
where TRefactoring : CodeRefactoringProvider, new()
{
var csTest = new CSharpCodeRefactoringTest<TRefactoring, DefaultVerifier>
{
CodeActionIndex = codeActionIndex,
TestState =
{
Sources = { inputSource },
AnalyzerConfigFiles = { { ("/.editorconfig", EditorConfig) } },
ReferenceAssemblies = new ReferenceAssemblies(
"net8.0",
new PackageIdentity(
"Microsoft.NETCore.App.Ref",
"8.0.0"),
Path.Combine("ref", "net8.0")
),
},
FixedState = { MarkupHandling = MarkupMode.IgnoreFixable, Sources = { fixedSource } },
};

csTest.TestState.AdditionalReferences
.AddRange(DriverReferenceAssemblies.Msdi.GetAdditionalReferences());

return csTest;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace Immediate.Handlers.Tests.CodeFixTests;

[System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1724:Type names should not match namespaces", Justification = "Not being consumed by other code")]
public sealed partial class Tests
public sealed partial class HandlerMethodMustExistCodeFixProviderTests
{
[Test]
public async Task HandleMethodDoesNotExist() =>
Expand Down
Loading