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
33 changes: 32 additions & 1 deletion src/LanguageServer/Protocol/DefaultCapabilitiesProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,15 +24,18 @@ internal sealed class ExperimentalCapabilitiesProvider : ICapabilitiesProvider
{
private readonly ImmutableArray<Lazy<CompletionProvider, CompletionProviderMetadata>> _completionProviders;
private readonly ImmutableArray<Lazy<ISignatureHelpProvider, OrderableLanguageMetadata>> _signatureHelpProviders;
private readonly IEnumerable<Lazy<ILspWillRenameListener, ILspWillRenameListenerMetadata>> _renameListeners;

[ImportingConstructor]
[Obsolete(MefConstruction.ImportingConstructorMessage, error: true)]
public ExperimentalCapabilitiesProvider(
[ImportMany] IEnumerable<Lazy<CompletionProvider, CompletionProviderMetadata>> completionProviders,
[ImportMany] IEnumerable<Lazy<ISignatureHelpProvider, OrderableLanguageMetadata>> signatureHelpProviders)
[ImportMany] IEnumerable<Lazy<ISignatureHelpProvider, OrderableLanguageMetadata>> signatureHelpProviders,
[ImportMany] IEnumerable<Lazy<ILspWillRenameListener, ILspWillRenameListenerMetadata>> 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()
Expand Down Expand Up @@ -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<FileOperationFilter>.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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Allows listening for workspace/didRenameFiles notifications.
/// </summary>
/// <remarks>
/// Although the registration for didRename allows specifying a document selector, and that registration is passed
Comment thread
davidwengier marked this conversation as resolved.
Outdated
/// 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.
/// </remarks>
internal interface ILspWillRenameListener
{
Task<WorkspaceEdit?> HandleWillRenameAsync(RenameFilesParams renameParams, RequestContext context, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -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; }
}
101 changes: 101 additions & 0 deletions src/LanguageServer/Protocol/Handler/Rename/WillRenameHandler.cs
Original file line number Diff line number Diff line change
@@ -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(
Comment thread
davidwengier marked this conversation as resolved.
[ImportMany] IEnumerable<Lazy<ILspWillRenameListener, ILspWillRenameListenerMetadata>> renameListeners) : ILspServiceRequestHandler<LSP.RenameFilesParams, WorkspaceEdit?>
{
public bool MutatesSolutionState => true;
public bool RequiresLSPSolution => true;

public TextDocumentIdentifier GetTextDocumentIdentifier(RenameParams request) => request.TextDocument;

public async Task<WorkspaceEdit?> HandleRequestAsync(RenameFilesParams request, RequestContext requestContext, CancellationToken cancellationToken)
{
using var _1 = PooledDictionary<string, ArrayBuilder<TextEdit>>.GetInstance(out var changesBuilder);
using var _2 = ArrayBuilder<SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>.GetInstance(out var documentChangesBuilder);

foreach (var listener in renameListeners)
{
var edit = await listener.Value.HandleWillRenameAsync(request, requestContext, cancellationToken).ConfigureAwait(false);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than have Roslyn bother to handle globs, and deal with the complexity of one willRenameFiles request possibly containing multiple files that multiple handlers would want to know about, it seemed easier to just have each handler check the actual files being renamed and see if they care. They'd want to do it anyway.


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<TextEdit>.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<string, TextEdit[]>();
foreach (var (path, editsBuilder) in changesBuilder)
{
changes[path] = editsBuilder.ToArrayAndFree();
}

return new WorkspaceEdit
{
Changes = changes
};
}

return new WorkspaceEdit
{
DocumentChanges = documentChangesBuilder.ToArray()
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ public TextDocumentSyncOptions? TextDocumentSync
/// </summary>
[JsonPropertyName("workspace")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public WorkspaceServerCapabilities? Workspace { get; init; }
public WorkspaceServerCapabilities? Workspace { get; set; }

/// <summary>
/// Gets or sets experimental server capabilities.
Expand Down
169 changes: 169 additions & 0 deletions src/LanguageServer/ProtocolUnitTests/Rename/WillRenameTests.cs
Original file line number Diff line number Diff line change
@@ -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<ILspWillRenameListener>().OfType<TestWillRenameListener1>().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<ILspWillRenameListener>().OfType<TestWillRenameListener1>().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<ILspWillRenameListener>().OfType<TestWillRenameListener1>().ToArray();

var expected = new WorkspaceEdit()
{
DocumentChanges = new SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>[] {
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<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>[] { 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<ILspWillRenameListener>().OfType<TestWillRenameListener1>().ToArray();

var expected = new WorkspaceEdit()
{
Changes = new Dictionary<string, TextEdit[]>
{
{ "file://file1.cs", []},
{ "file://file2.cs", [] }
}
};

listeners[0].Result = new WorkspaceEdit() { Changes = new Dictionary<string, TextEdit[]> { { "file://file1.cs", [] } } };
listeners[1].Result = new WorkspaceEdit() { Changes = new Dictionary<string, TextEdit[]> { { "file://file2.cs", [] } } };

var edit = await RunWillRenameAsync(testLspServer);
Assert.NotNull(edit);

AssertJsonEquals(expected, edit);
}

private static async Task<WorkspaceEdit> RunWillRenameAsync(TestLspServer testLspServer)
{
var renameParams = new RenameFilesParams();
return await testLspServer.ExecuteRequestAsync<LSP.RenameFilesParams, LSP.WorkspaceEdit>(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<WorkspaceEdit> 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
{
}
}
Loading