diff --git a/src/LanguageServer/Protocol/DefaultCapabilitiesProvider.cs b/src/LanguageServer/Protocol/DefaultCapabilitiesProvider.cs index d76244ff0c55..be05718d7ba3 100644 --- a/src/LanguageServer/Protocol/DefaultCapabilitiesProvider.cs +++ b/src/LanguageServer/Protocol/DefaultCapabilitiesProvider.cs @@ -11,6 +11,7 @@ using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.Completion.Providers; using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.LanguageServer.Handler; using Microsoft.CodeAnalysis.LanguageServer.Handler.Completion; using Microsoft.CodeAnalysis.LanguageServer.Handler.SemanticTokens; using Microsoft.CodeAnalysis.SignatureHelp; @@ -23,15 +24,18 @@ internal sealed class ExperimentalCapabilitiesProvider : ICapabilitiesProvider { private readonly ImmutableArray> _completionProviders; private readonly ImmutableArray> _signatureHelpProviders; + private readonly IEnumerable> _renameListeners; [ImportingConstructor] [Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] public ExperimentalCapabilitiesProvider( [ImportMany] IEnumerable> completionProviders, - [ImportMany] IEnumerable> signatureHelpProviders) + [ImportMany] IEnumerable> signatureHelpProviders, + [ImportMany] IEnumerable> renameListeners) { _completionProviders = [.. completionProviders.Where(lz => lz.Metadata.Language is LanguageNames.CSharp or LanguageNames.VisualBasic)]; _signatureHelpProviders = [.. signatureHelpProviders.Where(lz => lz.Metadata.Language is LanguageNames.CSharp or LanguageNames.VisualBasic)]; + _renameListeners = renameListeners; } public void Initialize() @@ -144,6 +148,33 @@ public ServerCapabilities GetCapabilities(ClientCapabilities clientCapabilities) }; } + if (clientCapabilities.Workspace?.FileOperations?.WillRename ?? false) + { + // Register for file rename notifications based on the registered rename listeners. + using var _ = PooledObjects.ArrayBuilder.GetInstance(out var filters); + foreach (var listener in _renameListeners) + { + filters.Add(new FileOperationFilter + { + Pattern = new FileOperationPattern { Glob = listener.Metadata.Glob } + }); + } + + if (filters.Count > 0) + { + capabilities.Workspace = new WorkspaceServerCapabilities + { + FileOperations = new WorkspaceFileOperationsServerCapabilities() + { + WillRename = new FileOperationRegistrationOptions() + { + Filters = filters.ToArray() + } + } + }; + } + } + return capabilities; } diff --git a/src/LanguageServer/Protocol/Handler/Rename/ExportLspWillRenameListenerAttribute.cs b/src/LanguageServer/Protocol/Handler/Rename/ExportLspWillRenameListenerAttribute.cs new file mode 100644 index 000000000000..27a381a11a25 --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/Rename/ExportLspWillRenameListenerAttribute.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Composition; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler; + +[MetadataAttribute] +[AttributeUsage(AttributeTargets.Class)] +internal class ExportLspWillRenameListenerAttribute(string glob) : ExportAttribute(typeof(ILspWillRenameListener)), ILspWillRenameListenerMetadata +{ + public string Glob { get; } = glob; +} diff --git a/src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListener.cs b/src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListener.cs new file mode 100644 index 000000000000..de7d4b78e055 --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListener.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading; +using System.Threading.Tasks; +using Roslyn.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler; + +/// +/// Allows listening for workspace/didRenameFiles notifications. +/// +/// +/// Although the registration for willRename allows specifying a document selector, and that registration is passed +/// along to the client, the LSP server itself does not filter notifications based on that selector. It is up to the +/// the listener to determine if it cares about the rename notification or not. If any listener returns an edit, no +/// further listeners are called. +/// +internal interface ILspWillRenameListener +{ + Task HandleWillRenameAsync(RenameFilesParams renameParams, RequestContext context, CancellationToken cancellationToken); +} diff --git a/src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListenerMetadata.cs b/src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListenerMetadata.cs new file mode 100644 index 000000000000..124761e4c9fb --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListenerMetadata.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler; + +internal interface ILspWillRenameListenerMetadata +{ + string Glob { get; } +} diff --git a/src/LanguageServer/Protocol/Handler/Rename/WillRenameHandler.cs b/src/LanguageServer/Protocol/Handler/Rename/WillRenameHandler.cs new file mode 100644 index 000000000000..fd838ddddff2 --- /dev/null +++ b/src/LanguageServer/Protocol/Handler/Rename/WillRenameHandler.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.PooledObjects; +using Roslyn.LanguageServer.Protocol; +using LSP = Roslyn.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.LanguageServer.Handler; + +[ExportCSharpVisualBasicStatelessLspService(typeof(WillRenameHandler)), Shared] +[Method(LSP.Methods.WorkspaceWillRenameFilesName)] +[method: ImportingConstructor] +[method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] +internal sealed class WillRenameHandler( + [ImportMany] IEnumerable> renameListeners) : ILspServiceRequestHandler +{ + public bool MutatesSolutionState => true; + public bool RequiresLSPSolution => true; + + public TextDocumentIdentifier GetTextDocumentIdentifier(RenameParams request) => request.TextDocument; + + public async Task HandleRequestAsync(RenameFilesParams request, RequestContext requestContext, CancellationToken cancellationToken) + { + using var _1 = PooledDictionary>.GetInstance(out var changesBuilder); + using var _2 = ArrayBuilder>.GetInstance(out var documentChangesBuilder); + + foreach (var listener in renameListeners) + { + var edit = await listener.Value.HandleWillRenameAsync(request, requestContext, cancellationToken).ConfigureAwait(false); + + if (edit is null) + { + continue; + } + + if (edit.Changes is { } changes) + { + foreach (var (path, edits) in changes) + { + if (!changesBuilder.TryGetValue(path, out var existingEdits)) + { + existingEdits = ArrayBuilder.GetInstance(); + changesBuilder.Add(path, existingEdits); + } + + existingEdits.AddRange(edits); + } + } + else if (edit.DocumentChanges is { } documentChanges) + { + if (documentChanges.TryGetFirst(out var textDocumentEdits)) + { + foreach (var textDocumentEdit in textDocumentEdits) + { + documentChangesBuilder.Add(textDocumentEdit); + } + } + else if (documentChanges.TryGetSecond(out var sumTypes)) + { + foreach (var sumType in sumTypes) + { + documentChangesBuilder.Add(sumType); + } + } + } + } + + if (changesBuilder.Count == 0 && documentChangesBuilder.Count == 0) + { + return null; + } + + Contract.ThrowIfTrue(changesBuilder.Count > 0 && documentChangesBuilder.Count > 0, "Cannot have both changes and documentChanges in a WorkspaceEdit. Please honour the client capabilities."); + + if (changesBuilder.Count > 0) + { + var changes = new Dictionary(); + foreach (var (path, editsBuilder) in changesBuilder) + { + changes[path] = editsBuilder.ToArrayAndFree(); + } + + return new WorkspaceEdit + { + Changes = changes + }; + } + + return new WorkspaceEdit + { + DocumentChanges = documentChangesBuilder.ToArray() + }; + } +} diff --git a/src/LanguageServer/Protocol/Protocol/ServerCapabilities.cs b/src/LanguageServer/Protocol/Protocol/ServerCapabilities.cs index 5c6aab253a35..e986cbc6bc3a 100644 --- a/src/LanguageServer/Protocol/Protocol/ServerCapabilities.cs +++ b/src/LanguageServer/Protocol/Protocol/ServerCapabilities.cs @@ -291,7 +291,7 @@ public TextDocumentSyncOptions? TextDocumentSync /// [JsonPropertyName("workspace")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public WorkspaceServerCapabilities? Workspace { get; init; } + public WorkspaceServerCapabilities? Workspace { get; set; } /// /// Gets or sets experimental server capabilities. diff --git a/src/LanguageServer/ProtocolUnitTests/Rename/WillRenameTests.cs b/src/LanguageServer/ProtocolUnitTests/Rename/WillRenameTests.cs new file mode 100644 index 000000000000..c6fefb8c008d --- /dev/null +++ b/src/LanguageServer/ProtocolUnitTests/Rename/WillRenameTests.cs @@ -0,0 +1,169 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Host.Mef; +using Microsoft.CodeAnalysis.LanguageServer.Handler; +using Microsoft.CodeAnalysis.Test.Utilities; +using Roslyn.LanguageServer.Protocol; +using Roslyn.Test.Utilities; +using Xunit; +using Xunit.Abstractions; +using LSP = Roslyn.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.Rename; + +public sealed class WillRenameTests(ITestOutputHelper testOutputHelper) : AbstractLanguageServerProtocolTests(testOutputHelper) +{ + protected override TestComposition Composition => base.Composition + .AddParts(typeof(TestWillRenameListener1)) + .AddParts(typeof(TestWillRenameListener2)); + + [Theory, CombinatorialData] + public async Task SetsCapabilities(bool mutatingLspWorkspace) + { + var clientCapabilities = new ClientCapabilities + { + Workspace = new WorkspaceClientCapabilities + { + FileOperations = new FileOperationsWorkspaceClientCapabilities + { + WillRename = true + }, + }, + }; + + await using var testLspServer = await CreateTestLspServerAsync("", mutatingLspWorkspace, clientCapabilities); + + var capabilities = testLspServer.GetServerCapabilities(); + + Assert.Collection(capabilities.Workspace.FileOperations.WillRename.Filters, + filter => Assert.Equal("*.cs", filter.Pattern.Glob), + filter => Assert.Equal("*.vb", filter.Pattern.Glob)); + } + + [Theory, CombinatorialData] + public async Task CallsListener(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerAsync("", mutatingLspWorkspace); + + var listeners = ((IMefHostExportProvider)testLspServer.TestWorkspace.Services.HostServices).GetExportedValues().OfType().ToArray(); + + // Need at least one change, or the result will be dropped + listeners[0].Result = new WorkspaceEdit() { DocumentChanges = new TextDocumentEdit[] { new() { } } }; + + var edit = await RunWillRenameAsync(testLspServer); + Assert.NotNull(edit); + } + + [Theory, CombinatorialData] + public async Task CombinesDocumentChanges(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerAsync("", mutatingLspWorkspace); + + var listeners = ((IMefHostExportProvider)testLspServer.TestWorkspace.Services.HostServices).GetExportedValues().OfType().ToArray(); + + var expected = new WorkspaceEdit() + { + DocumentChanges = new TextDocumentEdit[] { + new() { TextDocument = new() { DocumentUri = new("file://file1.cs") } }, + new() { TextDocument = new() { DocumentUri = new("file://file2.cs") } } + } + }; + + listeners[0].Result = new WorkspaceEdit() { DocumentChanges = new TextDocumentEdit[] { new() { TextDocument = new() { DocumentUri = new("file://file1.cs") } } } }; + listeners[1].Result = new WorkspaceEdit() { DocumentChanges = new TextDocumentEdit[] { new() { TextDocument = new() { DocumentUri = new("file://file2.cs") } } } }; + + var edit = await RunWillRenameAsync(testLspServer); + Assert.NotNull(edit); + + AssertJsonEquals(expected, edit); + } + + [Theory, CombinatorialData] + public async Task CombinesDocumentChanges_SumType(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerAsync("", mutatingLspWorkspace); + + var listeners = ((IMefHostExportProvider)testLspServer.TestWorkspace.Services.HostServices).GetExportedValues().OfType().ToArray(); + + var expected = new WorkspaceEdit() + { + DocumentChanges = new SumType[] { + new TextDocumentEdit() { TextDocument = new() { DocumentUri = new("file://file1.cs") } }, + new RenameFile() { OldDocumentUri = new("file://file2.cs") } + } + }; + + listeners[0].Result = new WorkspaceEdit() { DocumentChanges = new TextDocumentEdit[] { new() { TextDocument = new() { DocumentUri = new("file://file1.cs") } } } }; + listeners[1].Result = new WorkspaceEdit() { DocumentChanges = new SumType[] { new RenameFile() { OldDocumentUri = new("file://file2.cs") } } }; + + var edit = await RunWillRenameAsync(testLspServer); + Assert.NotNull(edit); + + AssertJsonEquals(expected, edit); + } + + [Theory, CombinatorialData] + public async Task CombinesDocumentChanges_Changes(bool mutatingLspWorkspace) + { + await using var testLspServer = await CreateTestLspServerAsync("", mutatingLspWorkspace); + + var listeners = ((IMefHostExportProvider)testLspServer.TestWorkspace.Services.HostServices).GetExportedValues().OfType().ToArray(); + + var expected = new WorkspaceEdit() + { + Changes = new Dictionary + { + { "file://file1.cs", []}, + { "file://file2.cs", [] } + } + }; + + listeners[0].Result = new WorkspaceEdit() { Changes = new Dictionary { { "file://file1.cs", [] } } }; + listeners[1].Result = new WorkspaceEdit() { Changes = new Dictionary { { "file://file2.cs", [] } } }; + + var edit = await RunWillRenameAsync(testLspServer); + Assert.NotNull(edit); + + AssertJsonEquals(expected, edit); + } + + private static async Task RunWillRenameAsync(TestLspServer testLspServer) + { + var renameParams = new RenameFilesParams(); + return await testLspServer.ExecuteRequestAsync(LSP.Methods.WorkspaceWillRenameFilesName, renameParams, CancellationToken.None); + } + + [ExportLspWillRenameListener("*.cs")] + [Shared] + [PartNotDiscoverable] + [method: ImportingConstructor] + [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] + private class TestWillRenameListener1() : ILspWillRenameListener + { + public WorkspaceEdit Result { get; set; } + + public Task HandleWillRenameAsync(RenameFilesParams renameParams, RequestContext context, CancellationToken cancellationToken) + { + return Task.FromResult(Result); + } + } + + [ExportLspWillRenameListener("*.vb")] + [Shared] + [PartNotDiscoverable] + [method: ImportingConstructor] + [method: Obsolete(MefConstruction.ImportingConstructorMessage, error: true)] + private class TestWillRenameListener2() : TestWillRenameListener1 + { + } +} diff --git a/src/Tools/ExternalAccess/Razor/Features/Cohost/AbstractRazorWillRenameListener.cs b/src/Tools/ExternalAccess/Razor/Features/Cohost/AbstractRazorWillRenameListener.cs new file mode 100644 index 000000000000..0773d6fae920 --- /dev/null +++ b/src/Tools/ExternalAccess/Razor/Features/Cohost/AbstractRazorWillRenameListener.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.LanguageServer.Handler; +using Roslyn.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; + +internal abstract class AbstractRazorWillRenameListener : ILspWillRenameListener +{ + Task ILspWillRenameListener.HandleWillRenameAsync(RenameFilesParams request, RequestContext context, CancellationToken cancellationToken) + { + var razorRequestContext = new RazorCohostRequestContext(context); + return HandleRequestAsync(request, razorRequestContext, cancellationToken); + } + + protected abstract Task HandleRequestAsync(RenameFilesParams request, RazorCohostRequestContext razorRequestContext, CancellationToken cancellationToken); +} diff --git a/src/Tools/ExternalAccess/Razor/Features/Cohost/ExportRazorWillRenameListenerAttribute.cs b/src/Tools/ExternalAccess/Razor/Features/Cohost/ExportRazorWillRenameListenerAttribute.cs new file mode 100644 index 000000000000..e17eaa4c7a3a --- /dev/null +++ b/src/Tools/ExternalAccess/Razor/Features/Cohost/ExportRazorWillRenameListenerAttribute.cs @@ -0,0 +1,9 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CodeAnalysis.LanguageServer.Handler; + +namespace Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; + +internal sealed class ExportRazorWillRenameListenerAttribute(string glob) : ExportLspWillRenameListenerAttribute(glob);