From 4425d18957bb4100c9a5ee929908c0495cd0a566 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Thu, 4 Dec 2025 17:34:37 +1100 Subject: [PATCH 1/5] Add willRenameFiles support, and an abstraction for people to implement --- .../AlwaysActivateInProcLanguageClient.cs | 32 +++++- .../ExportLspWillRenameListenerAttribute.cs | 15 +++ .../Handler/Rename/ILspWillRenameListener.cs | 23 ++++ .../Rename/ILspWillRenameListenerMetadata.cs | 10 ++ .../Handler/Rename/WillRenameHandler.cs | 101 ++++++++++++++++++ .../Protocol/Protocol/ServerCapabilities.cs | 2 +- 6 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/LanguageServer/Protocol/Handler/Rename/ExportLspWillRenameListenerAttribute.cs create mode 100644 src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListener.cs create mode 100644 src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListenerMetadata.cs create mode 100644 src/LanguageServer/Protocol/Handler/Rename/WillRenameHandler.cs diff --git a/src/EditorFeatures/Core/LanguageServer/AlwaysActivateInProcLanguageClient.cs b/src/EditorFeatures/Core/LanguageServer/AlwaysActivateInProcLanguageClient.cs index 4ac976af47db..552237e3fa10 100644 --- a/src/EditorFeatures/Core/LanguageServer/AlwaysActivateInProcLanguageClient.cs +++ b/src/EditorFeatures/Core/LanguageServer/AlwaysActivateInProcLanguageClient.cs @@ -10,6 +10,7 @@ using Microsoft.CodeAnalysis.Editor.Shared.Utilities; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.LanguageServer; +using Microsoft.CodeAnalysis.LanguageServer.Handler; using Microsoft.CodeAnalysis.LanguageServer.Handler.Diagnostics.DiagnosticSources; using Microsoft.CodeAnalysis.LanguageServer.Handler.SemanticTokens; using Microsoft.CodeAnalysis.Options; @@ -40,11 +41,13 @@ internal sealed class AlwaysActivateInProcLanguageClient( IThreadingContext threadingContext, ExportProvider exportProvider, IDiagnosticSourceManager diagnosticSourceManager, - [ImportMany] IEnumerable> buildOnlyDiagnostics) : AbstractInProcLanguageClient(lspServiceProvider, globalOptions, lspLoggerFactory, threadingContext, exportProvider) + [ImportMany] IEnumerable> buildOnlyDiagnostics, + [ImportMany] IEnumerable> renameListeners) : AbstractInProcLanguageClient(lspServiceProvider, globalOptions, lspLoggerFactory, threadingContext, exportProvider) { private readonly ExperimentalCapabilitiesProvider _experimentalCapabilitiesProvider = defaultCapabilitiesProvider; private readonly IDiagnosticSourceManager _diagnosticSourceManager = diagnosticSourceManager; private readonly IEnumerable> _buildOnlyDiagnostics = buildOnlyDiagnostics; + private readonly IEnumerable> _renameListeners = renameListeners; protected override ImmutableArray SupportedLanguages => ProtocolConstants.RoslynLspLanguages; @@ -127,6 +130,33 @@ public override ServerCapabilities GetCapabilities(ClientCapabilities clientCapa }; serverCapabilities.ImplementationProvider = true; + 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) + { + serverCapabilities.Workspace = new WorkspaceServerCapabilities + { + FileOperations = new WorkspaceFileOperationsServerCapabilities() + { + WillRename = new FileOperationRegistrationOptions() + { + Filters = filters.ToArray() + } + } + }; + } + } + return serverCapabilities; } 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..fbbf7c5d1191 --- /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 didRename 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. From 0072e495ffcf2136b65b41d6575fdd1c1b6f6a4b Mon Sep 17 00:00:00 2001 From: David Wengier Date: Thu, 4 Dec 2025 18:01:09 +1100 Subject: [PATCH 2/5] Allow Razor to export a willRename handler --- .../Cohost/AbstractRazorWillRenameListener.cs | 21 +++++++++++++++++++ .../ExportRazorWillRenameListenerAttribute.cs | 9 ++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/Tools/ExternalAccess/Razor/Features/Cohost/AbstractRazorWillRenameListener.cs create mode 100644 src/Tools/ExternalAccess/Razor/Features/Cohost/ExportRazorWillRenameListenerAttribute.cs 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); From 974d51c3e815bd1692c2f7edfba2d87f56d0a3c9 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Fri, 5 Dec 2025 12:09:16 +1100 Subject: [PATCH 3/5] Move capabilities so this works in VS Code --- .../AlwaysActivateInProcLanguageClient.cs | 32 +----------------- .../Protocol/DefaultCapabilitiesProvider.cs | 33 ++++++++++++++++++- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/EditorFeatures/Core/LanguageServer/AlwaysActivateInProcLanguageClient.cs b/src/EditorFeatures/Core/LanguageServer/AlwaysActivateInProcLanguageClient.cs index 552237e3fa10..4ac976af47db 100644 --- a/src/EditorFeatures/Core/LanguageServer/AlwaysActivateInProcLanguageClient.cs +++ b/src/EditorFeatures/Core/LanguageServer/AlwaysActivateInProcLanguageClient.cs @@ -10,7 +10,6 @@ using Microsoft.CodeAnalysis.Editor.Shared.Utilities; using Microsoft.CodeAnalysis.Host.Mef; using Microsoft.CodeAnalysis.LanguageServer; -using Microsoft.CodeAnalysis.LanguageServer.Handler; using Microsoft.CodeAnalysis.LanguageServer.Handler.Diagnostics.DiagnosticSources; using Microsoft.CodeAnalysis.LanguageServer.Handler.SemanticTokens; using Microsoft.CodeAnalysis.Options; @@ -41,13 +40,11 @@ internal sealed class AlwaysActivateInProcLanguageClient( IThreadingContext threadingContext, ExportProvider exportProvider, IDiagnosticSourceManager diagnosticSourceManager, - [ImportMany] IEnumerable> buildOnlyDiagnostics, - [ImportMany] IEnumerable> renameListeners) : AbstractInProcLanguageClient(lspServiceProvider, globalOptions, lspLoggerFactory, threadingContext, exportProvider) + [ImportMany] IEnumerable> buildOnlyDiagnostics) : AbstractInProcLanguageClient(lspServiceProvider, globalOptions, lspLoggerFactory, threadingContext, exportProvider) { private readonly ExperimentalCapabilitiesProvider _experimentalCapabilitiesProvider = defaultCapabilitiesProvider; private readonly IDiagnosticSourceManager _diagnosticSourceManager = diagnosticSourceManager; private readonly IEnumerable> _buildOnlyDiagnostics = buildOnlyDiagnostics; - private readonly IEnumerable> _renameListeners = renameListeners; protected override ImmutableArray SupportedLanguages => ProtocolConstants.RoslynLspLanguages; @@ -130,33 +127,6 @@ public override ServerCapabilities GetCapabilities(ClientCapabilities clientCapa }; serverCapabilities.ImplementationProvider = true; - 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) - { - serverCapabilities.Workspace = new WorkspaceServerCapabilities - { - FileOperations = new WorkspaceFileOperationsServerCapabilities() - { - WillRename = new FileOperationRegistrationOptions() - { - Filters = filters.ToArray() - } - } - }; - } - } - return serverCapabilities; } 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; } From eb48b33c4f1d372cace215bf4ef3926d73bd7e2b Mon Sep 17 00:00:00 2001 From: David Wengier Date: Fri, 5 Dec 2025 13:50:45 +1100 Subject: [PATCH 4/5] Tests --- .../Rename/WillRenameTests.cs | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/LanguageServer/ProtocolUnitTests/Rename/WillRenameTests.cs 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 + { + } +} From c8bfdc715d26f3c64732292ae928e2e8279ef381 Mon Sep 17 00:00:00 2001 From: David Wengier Date: Fri, 5 Dec 2025 13:53:42 +1100 Subject: [PATCH 5/5] Update src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListener.cs --- .../Protocol/Handler/Rename/ILspWillRenameListener.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListener.cs b/src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListener.cs index fbbf7c5d1191..de7d4b78e055 100644 --- a/src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListener.cs +++ b/src/LanguageServer/Protocol/Handler/Rename/ILspWillRenameListener.cs @@ -12,7 +12,7 @@ namespace Microsoft.CodeAnalysis.LanguageServer.Handler; /// Allows listening for workspace/didRenameFiles notifications. /// /// -/// Although the registration for didRename allows specifying a document selector, and that registration is passed +/// 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.