Skip to content

Commit 3c291cc

Browse files
authored
Add willRenameFiles support, and an abstraction for extenders to implement (#81549)
Part of dotnet/razor#8541 Adding functionality to Razor so that when a .razor file is renamed, the relevant component tags and any C# references are updated, so need willRename support. Since its a workspace request, if we implement it then Roslyn won't be able to, so best to support it in Roslyn and delegate to Razor as required.
1 parent 6dba733 commit 3c291cc

File tree

9 files changed

+381
-2
lines changed

9 files changed

+381
-2
lines changed

src/LanguageServer/Protocol/DefaultCapabilitiesProvider.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Microsoft.CodeAnalysis.Completion;
1212
using Microsoft.CodeAnalysis.Completion.Providers;
1313
using Microsoft.CodeAnalysis.Host.Mef;
14+
using Microsoft.CodeAnalysis.LanguageServer.Handler;
1415
using Microsoft.CodeAnalysis.LanguageServer.Handler.Completion;
1516
using Microsoft.CodeAnalysis.LanguageServer.Handler.SemanticTokens;
1617
using Microsoft.CodeAnalysis.SignatureHelp;
@@ -23,15 +24,18 @@ internal sealed class ExperimentalCapabilitiesProvider : ICapabilitiesProvider
2324
{
2425
private readonly ImmutableArray<Lazy<CompletionProvider, CompletionProviderMetadata>> _completionProviders;
2526
private readonly ImmutableArray<Lazy<ISignatureHelpProvider, OrderableLanguageMetadata>> _signatureHelpProviders;
27+
private readonly IEnumerable<Lazy<ILspWillRenameListener, ILspWillRenameListenerMetadata>> _renameListeners;
2628

2729
[ImportingConstructor]
2830
[Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
2931
public ExperimentalCapabilitiesProvider(
3032
[ImportMany] IEnumerable<Lazy<CompletionProvider, CompletionProviderMetadata>> completionProviders,
31-
[ImportMany] IEnumerable<Lazy<ISignatureHelpProvider, OrderableLanguageMetadata>> signatureHelpProviders)
33+
[ImportMany] IEnumerable<Lazy<ISignatureHelpProvider, OrderableLanguageMetadata>> signatureHelpProviders,
34+
[ImportMany] IEnumerable<Lazy<ILspWillRenameListener, ILspWillRenameListenerMetadata>> renameListeners)
3235
{
3336
_completionProviders = [.. completionProviders.Where(lz => lz.Metadata.Language is LanguageNames.CSharp or LanguageNames.VisualBasic)];
3437
_signatureHelpProviders = [.. signatureHelpProviders.Where(lz => lz.Metadata.Language is LanguageNames.CSharp or LanguageNames.VisualBasic)];
38+
_renameListeners = renameListeners;
3539
}
3640

3741
public void Initialize()
@@ -144,6 +148,33 @@ public ServerCapabilities GetCapabilities(ClientCapabilities clientCapabilities)
144148
};
145149
}
146150

151+
if (clientCapabilities.Workspace?.FileOperations?.WillRename ?? false)
152+
{
153+
// Register for file rename notifications based on the registered rename listeners.
154+
using var _ = PooledObjects.ArrayBuilder<FileOperationFilter>.GetInstance(out var filters);
155+
foreach (var listener in _renameListeners)
156+
{
157+
filters.Add(new FileOperationFilter
158+
{
159+
Pattern = new FileOperationPattern { Glob = listener.Metadata.Glob }
160+
});
161+
}
162+
163+
if (filters.Count > 0)
164+
{
165+
capabilities.Workspace = new WorkspaceServerCapabilities
166+
{
167+
FileOperations = new WorkspaceFileOperationsServerCapabilities()
168+
{
169+
WillRename = new FileOperationRegistrationOptions()
170+
{
171+
Filters = filters.ToArray()
172+
}
173+
}
174+
};
175+
}
176+
}
177+
147178
return capabilities;
148179
}
149180

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Composition;
7+
8+
namespace Microsoft.CodeAnalysis.LanguageServer.Handler;
9+
10+
[MetadataAttribute]
11+
[AttributeUsage(AttributeTargets.Class)]
12+
internal class ExportLspWillRenameListenerAttribute(string glob) : ExportAttribute(typeof(ILspWillRenameListener)), ILspWillRenameListenerMetadata
13+
{
14+
public string Glob { get; } = glob;
15+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Roslyn.LanguageServer.Protocol;
8+
9+
namespace Microsoft.CodeAnalysis.LanguageServer.Handler;
10+
11+
/// <summary>
12+
/// Allows listening for workspace/didRenameFiles notifications.
13+
/// </summary>
14+
/// <remarks>
15+
/// Although the registration for willRename allows specifying a document selector, and that registration is passed
16+
/// along to the client, the LSP server itself does not filter notifications based on that selector. It is up to the
17+
/// the listener to determine if it cares about the rename notification or not. If any listener returns an edit, no
18+
/// further listeners are called.
19+
/// </remarks>
20+
internal interface ILspWillRenameListener
21+
{
22+
Task<WorkspaceEdit?> HandleWillRenameAsync(RenameFilesParams renameParams, RequestContext context, CancellationToken cancellationToken);
23+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace Microsoft.CodeAnalysis.LanguageServer.Handler;
6+
7+
internal interface ILspWillRenameListenerMetadata
8+
{
9+
string Glob { get; }
10+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.Composition;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.CodeAnalysis.Host.Mef;
11+
using Microsoft.CodeAnalysis.PooledObjects;
12+
using Roslyn.LanguageServer.Protocol;
13+
using LSP = Roslyn.LanguageServer.Protocol;
14+
15+
namespace Microsoft.CodeAnalysis.LanguageServer.Handler;
16+
17+
[ExportCSharpVisualBasicStatelessLspService(typeof(WillRenameHandler)), Shared]
18+
[Method(LSP.Methods.WorkspaceWillRenameFilesName)]
19+
[method: ImportingConstructor]
20+
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
21+
internal sealed class WillRenameHandler(
22+
[ImportMany] IEnumerable<Lazy<ILspWillRenameListener, ILspWillRenameListenerMetadata>> renameListeners) : ILspServiceRequestHandler<LSP.RenameFilesParams, WorkspaceEdit?>
23+
{
24+
public bool MutatesSolutionState => true;
25+
public bool RequiresLSPSolution => true;
26+
27+
public TextDocumentIdentifier GetTextDocumentIdentifier(RenameParams request) => request.TextDocument;
28+
29+
public async Task<WorkspaceEdit?> HandleRequestAsync(RenameFilesParams request, RequestContext requestContext, CancellationToken cancellationToken)
30+
{
31+
using var _1 = PooledDictionary<string, ArrayBuilder<TextEdit>>.GetInstance(out var changesBuilder);
32+
using var _2 = ArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>.GetInstance(out var documentChangesBuilder);
33+
34+
foreach (var listener in renameListeners)
35+
{
36+
var edit = await listener.Value.HandleWillRenameAsync(request, requestContext, cancellationToken).ConfigureAwait(false);
37+
38+
if (edit is null)
39+
{
40+
continue;
41+
}
42+
43+
if (edit.Changes is { } changes)
44+
{
45+
foreach (var (path, edits) in changes)
46+
{
47+
if (!changesBuilder.TryGetValue(path, out var existingEdits))
48+
{
49+
existingEdits = ArrayBuilder<TextEdit>.GetInstance();
50+
changesBuilder.Add(path, existingEdits);
51+
}
52+
53+
existingEdits.AddRange(edits);
54+
}
55+
}
56+
else if (edit.DocumentChanges is { } documentChanges)
57+
{
58+
if (documentChanges.TryGetFirst(out var textDocumentEdits))
59+
{
60+
foreach (var textDocumentEdit in textDocumentEdits)
61+
{
62+
documentChangesBuilder.Add(textDocumentEdit);
63+
}
64+
}
65+
else if (documentChanges.TryGetSecond(out var sumTypes))
66+
{
67+
foreach (var sumType in sumTypes)
68+
{
69+
documentChangesBuilder.Add(sumType);
70+
}
71+
}
72+
}
73+
}
74+
75+
if (changesBuilder.Count == 0 && documentChangesBuilder.Count == 0)
76+
{
77+
return null;
78+
}
79+
80+
Contract.ThrowIfTrue(changesBuilder.Count > 0 && documentChangesBuilder.Count > 0, "Cannot have both changes and documentChanges in a WorkspaceEdit. Please honour the client capabilities.");
81+
82+
if (changesBuilder.Count > 0)
83+
{
84+
var changes = new Dictionary<string, TextEdit[]>();
85+
foreach (var (path, editsBuilder) in changesBuilder)
86+
{
87+
changes[path] = editsBuilder.ToArrayAndFree();
88+
}
89+
90+
return new WorkspaceEdit
91+
{
92+
Changes = changes
93+
};
94+
}
95+
96+
return new WorkspaceEdit
97+
{
98+
DocumentChanges = documentChangesBuilder.ToArray()
99+
};
100+
}
101+
}

src/LanguageServer/Protocol/Protocol/ServerCapabilities.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ public TextDocumentSyncOptions? TextDocumentSync
291291
/// </summary>
292292
[JsonPropertyName("workspace")]
293293
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
294-
public WorkspaceServerCapabilities? Workspace { get; init; }
294+
public WorkspaceServerCapabilities? Workspace { get; set; }
295295

296296
/// <summary>
297297
/// Gets or sets experimental server capabilities.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
#nullable disable
6+
7+
using System;
8+
using System.Collections.Generic;
9+
using System.Composition;
10+
using System.Linq;
11+
using System.Threading;
12+
using System.Threading.Tasks;
13+
using Microsoft.CodeAnalysis.Host.Mef;
14+
using Microsoft.CodeAnalysis.LanguageServer.Handler;
15+
using Microsoft.CodeAnalysis.Test.Utilities;
16+
using Roslyn.LanguageServer.Protocol;
17+
using Roslyn.Test.Utilities;
18+
using Xunit;
19+
using Xunit.Abstractions;
20+
using LSP = Roslyn.LanguageServer.Protocol;
21+
22+
namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.Rename;
23+
24+
public sealed class WillRenameTests(ITestOutputHelper testOutputHelper) : AbstractLanguageServerProtocolTests(testOutputHelper)
25+
{
26+
protected override TestComposition Composition => base.Composition
27+
.AddParts(typeof(TestWillRenameListener1))
28+
.AddParts(typeof(TestWillRenameListener2));
29+
30+
[Theory, CombinatorialData]
31+
public async Task SetsCapabilities(bool mutatingLspWorkspace)
32+
{
33+
var clientCapabilities = new ClientCapabilities
34+
{
35+
Workspace = new WorkspaceClientCapabilities
36+
{
37+
FileOperations = new FileOperationsWorkspaceClientCapabilities
38+
{
39+
WillRename = true
40+
},
41+
},
42+
};
43+
44+
await using var testLspServer = await CreateTestLspServerAsync("", mutatingLspWorkspace, clientCapabilities);
45+
46+
var capabilities = testLspServer.GetServerCapabilities();
47+
48+
Assert.Collection(capabilities.Workspace.FileOperations.WillRename.Filters,
49+
filter => Assert.Equal("*.cs", filter.Pattern.Glob),
50+
filter => Assert.Equal("*.vb", filter.Pattern.Glob));
51+
}
52+
53+
[Theory, CombinatorialData]
54+
public async Task CallsListener(bool mutatingLspWorkspace)
55+
{
56+
await using var testLspServer = await CreateTestLspServerAsync("", mutatingLspWorkspace);
57+
58+
var listeners = ((IMefHostExportProvider)testLspServer.TestWorkspace.Services.HostServices).GetExportedValues<ILspWillRenameListener>().OfType<TestWillRenameListener1>().ToArray();
59+
60+
// Need at least one change, or the result will be dropped
61+
listeners[0].Result = new WorkspaceEdit() { DocumentChanges = new TextDocumentEdit[] { new() { } } };
62+
63+
var edit = await RunWillRenameAsync(testLspServer);
64+
Assert.NotNull(edit);
65+
}
66+
67+
[Theory, CombinatorialData]
68+
public async Task CombinesDocumentChanges(bool mutatingLspWorkspace)
69+
{
70+
await using var testLspServer = await CreateTestLspServerAsync("", mutatingLspWorkspace);
71+
72+
var listeners = ((IMefHostExportProvider)testLspServer.TestWorkspace.Services.HostServices).GetExportedValues<ILspWillRenameListener>().OfType<TestWillRenameListener1>().ToArray();
73+
74+
var expected = new WorkspaceEdit()
75+
{
76+
DocumentChanges = new TextDocumentEdit[] {
77+
new() { TextDocument = new() { DocumentUri = new("file://file1.cs") } },
78+
new() { TextDocument = new() { DocumentUri = new("file://file2.cs") } }
79+
}
80+
};
81+
82+
listeners[0].Result = new WorkspaceEdit() { DocumentChanges = new TextDocumentEdit[] { new() { TextDocument = new() { DocumentUri = new("file://file1.cs") } } } };
83+
listeners[1].Result = new WorkspaceEdit() { DocumentChanges = new TextDocumentEdit[] { new() { TextDocument = new() { DocumentUri = new("file://file2.cs") } } } };
84+
85+
var edit = await RunWillRenameAsync(testLspServer);
86+
Assert.NotNull(edit);
87+
88+
AssertJsonEquals(expected, edit);
89+
}
90+
91+
[Theory, CombinatorialData]
92+
public async Task CombinesDocumentChanges_SumType(bool mutatingLspWorkspace)
93+
{
94+
await using var testLspServer = await CreateTestLspServerAsync("", mutatingLspWorkspace);
95+
96+
var listeners = ((IMefHostExportProvider)testLspServer.TestWorkspace.Services.HostServices).GetExportedValues<ILspWillRenameListener>().OfType<TestWillRenameListener1>().ToArray();
97+
98+
var expected = new WorkspaceEdit()
99+
{
100+
DocumentChanges = new SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>[] {
101+
new TextDocumentEdit() { TextDocument = new() { DocumentUri = new("file://file1.cs") } },
102+
new RenameFile() { OldDocumentUri = new("file://file2.cs") }
103+
}
104+
};
105+
106+
listeners[0].Result = new WorkspaceEdit() { DocumentChanges = new TextDocumentEdit[] { new() { TextDocument = new() { DocumentUri = new("file://file1.cs") } } } };
107+
listeners[1].Result = new WorkspaceEdit() { DocumentChanges = new SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>[] { new RenameFile() { OldDocumentUri = new("file://file2.cs") } } };
108+
109+
var edit = await RunWillRenameAsync(testLspServer);
110+
Assert.NotNull(edit);
111+
112+
AssertJsonEquals(expected, edit);
113+
}
114+
115+
[Theory, CombinatorialData]
116+
public async Task CombinesDocumentChanges_Changes(bool mutatingLspWorkspace)
117+
{
118+
await using var testLspServer = await CreateTestLspServerAsync("", mutatingLspWorkspace);
119+
120+
var listeners = ((IMefHostExportProvider)testLspServer.TestWorkspace.Services.HostServices).GetExportedValues<ILspWillRenameListener>().OfType<TestWillRenameListener1>().ToArray();
121+
122+
var expected = new WorkspaceEdit()
123+
{
124+
Changes = new Dictionary<string, TextEdit[]>
125+
{
126+
{ "file://file1.cs", []},
127+
{ "file://file2.cs", [] }
128+
}
129+
};
130+
131+
listeners[0].Result = new WorkspaceEdit() { Changes = new Dictionary<string, TextEdit[]> { { "file://file1.cs", [] } } };
132+
listeners[1].Result = new WorkspaceEdit() { Changes = new Dictionary<string, TextEdit[]> { { "file://file2.cs", [] } } };
133+
134+
var edit = await RunWillRenameAsync(testLspServer);
135+
Assert.NotNull(edit);
136+
137+
AssertJsonEquals(expected, edit);
138+
}
139+
140+
private static async Task<WorkspaceEdit> RunWillRenameAsync(TestLspServer testLspServer)
141+
{
142+
var renameParams = new RenameFilesParams();
143+
return await testLspServer.ExecuteRequestAsync<LSP.RenameFilesParams, LSP.WorkspaceEdit>(LSP.Methods.WorkspaceWillRenameFilesName, renameParams, CancellationToken.None);
144+
}
145+
146+
[ExportLspWillRenameListener("*.cs")]
147+
[Shared]
148+
[PartNotDiscoverable]
149+
[method: ImportingConstructor]
150+
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
151+
private class TestWillRenameListener1() : ILspWillRenameListener
152+
{
153+
public WorkspaceEdit Result { get; set; }
154+
155+
public Task<WorkspaceEdit> HandleWillRenameAsync(RenameFilesParams renameParams, RequestContext context, CancellationToken cancellationToken)
156+
{
157+
return Task.FromResult(Result);
158+
}
159+
}
160+
161+
[ExportLspWillRenameListener("*.vb")]
162+
[Shared]
163+
[PartNotDiscoverable]
164+
[method: ImportingConstructor]
165+
[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
166+
private class TestWillRenameListener2() : TestWillRenameListener1
167+
{
168+
}
169+
}

0 commit comments

Comments
 (0)