Skip to content

Commit f1db6ea

Browse files
Support basic refactoring from static to sealed
1 parent 5cae34a commit f1db6ea

8 files changed

Lines changed: 464 additions & 3 deletions

Directory.Packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.8.0" />
2020
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
2121
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.2" />
22-
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.2" />
22+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.2" />
23+
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.CodeRefactoring.Testing" Version="1.1.2" />
2324
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.8.0" />
2425
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.8.0" />
2526
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.7" />

src/Common/ITypeSymbolExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ typeSymbol is INamedTypeSymbol
9696
},
9797
};
9898

99-
public static bool IsCancellationToken(this ITypeSymbol typeSymbol) =>
99+
public static bool IsCancellationToken(this ITypeSymbol? typeSymbol) =>
100100
typeSymbol is INamedTypeSymbol
101101
{
102102
Name: "CancellationToken",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CodeRefactorings;
4+
using Microsoft.CodeAnalysis.Text;
5+
6+
namespace Immediate.Handlers.CodeFixes;
7+
8+
[ExcludeFromCodeCoverage]
9+
internal static class RefactoringExtensions
10+
{
11+
internal static void Deconstruct(this CodeRefactoringContext context, out Document document, out TextSpan span, out CancellationToken cancellationToken)
12+
{
13+
document = context.Document;
14+
span = context.Span;
15+
cancellationToken = context.CancellationToken;
16+
}
17+
18+
public static async ValueTask<SyntaxNode> GetRequiredSyntaxRootAsync(this Document document, CancellationToken cancellationToken)
19+
{
20+
if (document.TryGetSyntaxRoot(out var root))
21+
return root;
22+
23+
return await document.GetSyntaxRootAsync(cancellationToken)
24+
?? throw new InvalidOperationException();
25+
}
26+
27+
public static async ValueTask<SemanticModel> GetRequiredSemanticModelAsync(this Document document, CancellationToken cancellationToken)
28+
{
29+
if (document.TryGetSemanticModel(out var semanticModel))
30+
return semanticModel;
31+
32+
return await document.GetSemanticModelAsync(cancellationToken)
33+
?? throw new InvalidOperationException();
34+
}
35+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CodeActions;
3+
using Microsoft.CodeAnalysis.CodeRefactorings;
4+
using Microsoft.CodeAnalysis.CSharp;
5+
using Microsoft.CodeAnalysis.CSharp.Syntax;
6+
using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
7+
8+
namespace Immediate.Handlers.CodeFixes;
9+
10+
[ExportCodeRefactoringProvider(LanguageNames.CSharp, Name = "Convert to instance handler")]
11+
public sealed class StaticToSealedHandlerRefactoringProvider : CodeRefactoringProvider
12+
{
13+
public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context)
14+
{
15+
var (document, span, token) = context;
16+
token.ThrowIfCancellationRequested();
17+
18+
if (await document.GetRequiredSyntaxRootAsync(token) is not CompilationUnitSyntax root)
19+
return;
20+
21+
var model = await document.GetRequiredSemanticModelAsync(token);
22+
23+
switch (root.FindNode(span))
24+
{
25+
case ClassDeclarationSyntax cds:
26+
{
27+
if (model.GetDeclaredSymbol(cds, token) is not INamedTypeSymbol { IsStatic: true } container)
28+
return;
29+
30+
if (!container.GetAttributes().Any(a => a.AttributeClass.IsHandlerAttribute()))
31+
return;
32+
33+
var method = container.GetMembers()
34+
.OfType<IMethodSymbol>()
35+
.FirstOrDefault(m => m is { IsStatic: true, Name: "Handle" or "HandleAsync" });
36+
37+
if (method is null)
38+
return;
39+
40+
var mds = (MethodDeclarationSyntax)await method
41+
.DeclaringSyntaxReferences[0]
42+
.GetSyntaxAsync(token);
43+
44+
var service = new RefactoringService(
45+
document,
46+
model,
47+
root,
48+
cds,
49+
mds
50+
);
51+
52+
context.RegisterRefactoring(
53+
CodeAction.Create(
54+
title: "Convert to instance handler",
55+
createChangedDocument: service.ConvertToInstanceHandler,
56+
equivalenceKey: nameof(StaticToSealedHandlerRefactoringProvider)
57+
)
58+
);
59+
60+
break;
61+
}
62+
63+
case MethodDeclarationSyntax mds:
64+
{
65+
if (model.GetDeclaredSymbol(mds, token) is not IMethodSymbol
66+
{
67+
IsStatic: true,
68+
Name: "Handle" or "HandleAsync",
69+
ContainingType: INamedTypeSymbol { IsStatic: true } container,
70+
} method)
71+
{
72+
return;
73+
}
74+
75+
if (!container.GetAttributes().Any(a => a.AttributeClass.IsHandlerAttribute()))
76+
return;
77+
78+
var service = new RefactoringService(
79+
document,
80+
model,
81+
root,
82+
(ClassDeclarationSyntax)mds.Parent!,
83+
mds
84+
);
85+
86+
context.RegisterRefactoring(
87+
CodeAction.Create(
88+
title: "Convert to instance handler",
89+
createChangedDocument: service.ConvertToInstanceHandler,
90+
equivalenceKey: nameof(StaticToSealedHandlerRefactoringProvider)
91+
)
92+
);
93+
94+
break;
95+
}
96+
97+
default:
98+
break;
99+
}
100+
}
101+
102+
}
103+
104+
file sealed class RefactoringService(
105+
Document document,
106+
SemanticModel model,
107+
CompilationUnitSyntax documentRoot,
108+
ClassDeclarationSyntax classDeclarationSyntax,
109+
MethodDeclarationSyntax methodDeclarationSyntax
110+
)
111+
{
112+
public Task<Document> ConvertToInstanceHandler(
113+
CancellationToken token
114+
)
115+
{
116+
var methodParameters = methodDeclarationSyntax.ParameterList.Parameters;
117+
118+
var isLastParamCancellationToken =
119+
(model.GetSymbolInfo(methodParameters[^1].Type!, token).Symbol as INamedTypeSymbol)
120+
.IsCancellationToken();
121+
122+
var classParameters = methodParameters
123+
.Skip(1)
124+
.Take(methodParameters.Count - (isLastParamCancellationToken ? 2 : 1))
125+
.Select(p => p.WithTrailingTrivia(ElasticSpace))
126+
.ToList();
127+
128+
var newMethodParameters = methodParameters.RemoveParametersUntilCount(isLastParamCancellationToken ? 2 : 1);
129+
130+
var newMethodDeclarationSyntax = methodDeclarationSyntax
131+
.WithParameterList(
132+
methodDeclarationSyntax.ParameterList
133+
.WithParameters(newMethodParameters)
134+
)
135+
.WithModifiers(
136+
methodDeclarationSyntax.Modifiers
137+
.RemoveAll(static p => p.IsKind(SyntaxKind.StaticKeyword))
138+
);
139+
140+
var newClassDeclarationSyntax = classDeclarationSyntax
141+
.ReplaceNode(methodDeclarationSyntax, newMethodDeclarationSyntax)
142+
.WithModifiers(
143+
classDeclarationSyntax.Modifiers
144+
.RemoveAll(static p => p.IsKind(SyntaxKind.StaticKeyword))
145+
.Insert(classDeclarationSyntax.Modifiers.Count - 2, Token(SyntaxKind.SealedKeyword).WithTrailingTrivia(ElasticSpace))
146+
);
147+
148+
if (classParameters.Count > 0)
149+
{
150+
newClassDeclarationSyntax = newClassDeclarationSyntax
151+
.WithParameterList(
152+
ParameterList(SeparatedList(classParameters))
153+
)
154+
.WithIdentifier(classDeclarationSyntax.Identifier.WithoutTrivia());
155+
}
156+
157+
return Task.FromResult(document.WithSyntaxRoot(documentRoot.ReplaceNode(classDeclarationSyntax, newClassDeclarationSyntax)));
158+
}
159+
}
160+
161+
file static class SyntaxExtensions
162+
{
163+
public static SeparatedSyntaxList<ParameterSyntax> RemoveParametersUntilCount(
164+
this SeparatedSyntaxList<ParameterSyntax> nodes,
165+
int count
166+
)
167+
{
168+
while (nodes.Count > count)
169+
nodes = nodes.RemoveAt(1);
170+
return nodes;
171+
}
172+
173+
public static SyntaxTokenList RemoveAll(
174+
this SyntaxTokenList list,
175+
Func<SyntaxToken, bool> filter
176+
)
177+
{
178+
for (var i = 0; i < list.Count; i++)
179+
{
180+
if (filter(list[i]))
181+
{
182+
list = list.RemoveAt(i);
183+
i--;
184+
}
185+
}
186+
187+
return list;
188+
}
189+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using Immediate.Handlers.Tests.Helpers;
3+
using Microsoft.CodeAnalysis.CodeRefactorings;
4+
using Microsoft.CodeAnalysis.CSharp.Testing;
5+
using Microsoft.CodeAnalysis.Testing;
6+
7+
namespace Immediate.Handlers.Tests.CodeFixTests;
8+
9+
public static class CodeRefactoringTestHelper
10+
{
11+
private const string EditorConfig =
12+
"""
13+
root = true
14+
15+
[*.cs]
16+
charset = utf-8
17+
indent_style = tab
18+
insert_final_newline = true
19+
indent_size = 4
20+
""";
21+
22+
public static CSharpCodeRefactoringTest<TRefactoring, DefaultVerifier> CreateCodeRefactoringTest<TRefactoring>(
23+
[StringSyntax("c#-test")] string inputSource,
24+
[StringSyntax("c#-test")] string fixedSource,
25+
int codeActionIndex = 0
26+
)
27+
where TRefactoring : CodeRefactoringProvider, new()
28+
{
29+
var csTest = new CSharpCodeRefactoringTest<TRefactoring, DefaultVerifier>
30+
{
31+
CodeActionIndex = codeActionIndex,
32+
TestState =
33+
{
34+
Sources = { inputSource },
35+
AnalyzerConfigFiles = { { ("/.editorconfig", EditorConfig) } },
36+
ReferenceAssemblies = new ReferenceAssemblies(
37+
"net8.0",
38+
new PackageIdentity(
39+
"Microsoft.NETCore.App.Ref",
40+
"8.0.0"),
41+
Path.Combine("ref", "net8.0")
42+
),
43+
},
44+
FixedState = { MarkupHandling = MarkupMode.IgnoreFixable, Sources = { fixedSource } },
45+
};
46+
47+
csTest.TestState.AdditionalReferences
48+
.AddRange(DriverReferenceAssemblies.Msdi.GetAdditionalReferences());
49+
50+
return csTest;
51+
}
52+
}

tests/Immediate.Handlers.Tests/CodeFixTests/Tests.HandleMethodDoesNotExist.cs renamed to tests/Immediate.Handlers.Tests/CodeFixTests/HandlerMethodMustExistCodeFixProviderTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
namespace Immediate.Handlers.Tests.CodeFixTests;
66

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

0 commit comments

Comments
 (0)