From 0961ceead7f51e3ce3151c822f6b050a3e9ff8c3 Mon Sep 17 00:00:00 2001 From: Sebastian Thomschke Date: Fri, 21 Nov 2025 16:59:30 +0100 Subject: [PATCH 1/3] feat: language-server powered file/folder create, rename, delete Project Explorer actions now call LSP willCreate/willRename/willDelete and apply returned edits when supported. Supports both files and folders; honors server filters (glob + file/folder kind). Ensures pending didChange is sent before didClose to preserve LSP event order during renames. --- org.eclipse.lsp4e.test/META-INF/MANIFEST.MF | 1 + .../rename/FileOperationParticipantsTest.java | 157 ++++++++++++++ .../FolderOperationParticipantsTest.java | 195 ++++++++++++++++++ .../rename/LSPCreateParticipantTest.java | 115 +++++++++++ .../rename/LSPDeleteParticipantTest.java | 116 +++++++++++ .../rename/LSPMoveParticipantTest.java | 127 ++++++++++++ .../rename/LSPRenameParticipantTest.java | 139 +++++++++++++ .../META-INF/MANIFEST.MF | 2 +- .../tests/mock/MockWorkspaceService.java | 39 ++++ org.eclipse.lsp4e/plugin.xml | 65 ++++++ .../lsp4e/DocumentContentSynchronizer.java | 10 +- .../lsp4e/internal/SupportedFeatures.java | 18 +- .../rename/LSPCreateParticipant.java | 83 ++++++++ .../rename/LSPDeleteParticipant.java | 81 ++++++++ .../LSPFileOperationParticipantSupport.java | 130 ++++++++++++ .../operations/rename/LSPMoveParticipant.java | 99 +++++++++ .../rename/LSPRenameParticipant.java | 93 +++++++++ 17 files changed, 1463 insertions(+), 7 deletions(-) create mode 100644 org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FileOperationParticipantsTest.java create mode 100644 org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FolderOperationParticipantsTest.java create mode 100644 org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPCreateParticipantTest.java create mode 100644 org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPDeleteParticipantTest.java create mode 100644 org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPMoveParticipantTest.java create mode 100644 org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPRenameParticipantTest.java create mode 100644 org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPCreateParticipant.java create mode 100644 org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPDeleteParticipant.java create mode 100644 org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java create mode 100644 org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPMoveParticipant.java create mode 100644 org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPRenameParticipant.java diff --git a/org.eclipse.lsp4e.test/META-INF/MANIFEST.MF b/org.eclipse.lsp4e.test/META-INF/MANIFEST.MF index 7a360a319..87dc67ea6 100644 --- a/org.eclipse.lsp4e.test/META-INF/MANIFEST.MF +++ b/org.eclipse.lsp4e.test/META-INF/MANIFEST.MF @@ -22,6 +22,7 @@ Require-Bundle: org.eclipse.core.runtime, org.eclipse.lsp4j, org.eclipse.lsp4j.debug, org.eclipse.jdt.annotation, + org.eclipse.ltk.core.refactoring, org.eclipse.ui.tests.harness, org.eclipse.ui.monitoring, org.eclipse.core.variables, diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FileOperationParticipantsTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FileOperationParticipantsTest.java new file mode 100644 index 000000000..eef51a4e5 --- /dev/null +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FileOperationParticipantsTest.java @@ -0,0 +1,157 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.test.operations.rename; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; +import java.util.List; + +import org.eclipse.core.resources.IFile; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.operations.rename.LSPFileOperationParticipantSupport; +import org.eclipse.lsp4e.test.utils.AbstractTestWithProject; +import org.eclipse.lsp4e.test.utils.TestUtils; +import org.eclipse.lsp4e.tests.mock.MockLanguageServer; +import org.eclipse.lsp4e.tests.mock.MockWorkspaceService; +import org.eclipse.lsp4j.CreateFilesParams; +import org.eclipse.lsp4j.DeleteFilesParams; +import org.eclipse.lsp4j.FileCreate; +import org.eclipse.lsp4j.FileDelete; +import org.eclipse.lsp4j.FileOperationFilter; +import org.eclipse.lsp4j.FileOperationOptions; +import org.eclipse.lsp4j.FileOperationPattern; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.lsp4j.FileRename; +import org.eclipse.lsp4j.RenameFilesParams; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.WorkspaceServerCapabilities; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class FileOperationParticipantsTest extends AbstractTestWithProject { + + @BeforeEach + void setupCaps() { + MockLanguageServer.reset(() -> { + ServerCapabilities caps = MockLanguageServer.defaultServerCapabilities(); + var ws = new WorkspaceServerCapabilities(); + var fileOps = new FileOperationsServerCapabilities(); + fileOps.setWillCreate(new FileOperationOptions()); + fileOps.setWillRename(new FileOperationOptions()); + fileOps.setWillDelete(new FileOperationOptions()); + ws.setFileOperations(fileOps); + caps.setWorkspace(ws); + return caps; + }); + } + + @Test + void testFilterGlobMatching() throws Exception { + // Reconfigure with a forward-slash glob filter + MockLanguageServer.reset(() -> { + ServerCapabilities caps = MockLanguageServer.defaultServerCapabilities(); + var ws = new WorkspaceServerCapabilities(); + var fileOps = new FileOperationsServerCapabilities(); + var pattern = new FileOperationPattern("**/*.lspt"); + var filter = new FileOperationFilter(pattern, "file"); + var opts = new FileOperationOptions(List.of(filter)); + fileOps.setWillRename(opts); + ws.setFileOperations(fileOps); + caps.setWorkspace(ws); + return caps; + }); + + IFile file = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(file); + TestUtils.waitForAndAssertCondition(5_000, () -> LanguageServers.forProject(project).anyMatching()); + var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(file, + FileOperationsServerCapabilities::getWillRename); + assertTrue(!servers.isEmpty()); + } + + @Test + void testWillRename() throws Exception { + IFile file = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(file); + URI uri = LSPEclipseUtils.toUri(file); + assertNotNull(uri); + + // Ensure an LS is available + assertTrue(LanguageServers.forProject(project).anyMatching()); + + var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(file, + FileOperationsServerCapabilities::getWillRename); + assertTrue(!servers.isEmpty()); + + var params = new RenameFilesParams(); + URI newUri = LSPEclipseUtils.toUri(project.getFile("renamed-" + file.getName())); + params.getFiles().add(new FileRename(uri.toString(), newUri.toString())); + + // Exercise helper to trigger server call + LSPFileOperationParticipantSupport.computePreChange("rename", params, servers, + (ws, p) -> ws.willRenameFiles(p)); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillRename()); + assertEquals(1, ws.getLastWillRename().getFiles().size()); + assertEquals(uri.toString(), ws.getLastWillRename().getFiles().get(0).getOldUri()); + assertEquals(newUri.toString(), ws.getLastWillRename().getFiles().get(0).getNewUri()); + } + + @Test + void testWillCreate() throws Exception { + IFile file = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(file); + URI uri = LSPEclipseUtils.toUri(file); + assertNotNull(uri); + + var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(file, + FileOperationsServerCapabilities::getWillCreate); + assertTrue(!servers.isEmpty()); + + var params = new CreateFilesParams(); + params.getFiles().add(new FileCreate(uri.toString())); + + LSPFileOperationParticipantSupport.computePreChange("create", params, servers, + (ws, p) -> ws.willCreateFiles(p)); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillCreate()); + assertEquals(1, ws.getLastWillCreate().getFiles().size()); + assertEquals(uri.toString(), ws.getLastWillCreate().getFiles().get(0).getUri()); + } + + @Test + void testWillDelete() throws Exception { + IFile file = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(file); + URI uri = LSPEclipseUtils.toUri(file); + assertNotNull(uri); + + var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(file, + FileOperationsServerCapabilities::getWillDelete); + assertTrue(!servers.isEmpty()); + + var params = new DeleteFilesParams(); + params.getFiles().add(new FileDelete(uri.toString())); + + LSPFileOperationParticipantSupport.computePreChange("delete", params, servers, + (ws, p) -> ws.willDeleteFiles(p)); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillDelete()); + assertEquals(1, ws.getLastWillDelete().getFiles().size()); + assertEquals(uri.toString(), ws.getLastWillDelete().getFiles().get(0).getUri()); + } +} diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FolderOperationParticipantsTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FolderOperationParticipantsTest.java new file mode 100644 index 000000000..d41e1b703 --- /dev/null +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FolderOperationParticipantsTest.java @@ -0,0 +1,195 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.test.operations.rename; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; +import java.util.List; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.operations.rename.LSPFileOperationParticipantSupport; +import org.eclipse.lsp4e.test.utils.AbstractTestWithProject; +import org.eclipse.lsp4e.test.utils.TestUtils; +import org.eclipse.lsp4e.tests.mock.MockLanguageServer; +import org.eclipse.lsp4e.tests.mock.MockWorkspaceService; +import org.eclipse.lsp4j.CreateFilesParams; +import org.eclipse.lsp4j.DeleteFilesParams; +import org.eclipse.lsp4j.FileCreate; +import org.eclipse.lsp4j.FileDelete; +import org.eclipse.lsp4j.FileOperationFilter; +import org.eclipse.lsp4j.FileOperationOptions; +import org.eclipse.lsp4j.FileOperationPattern; +import org.eclipse.lsp4j.FileOperationPatternKind; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.lsp4j.FileRename; +import org.eclipse.lsp4j.RenameFilesParams; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.WorkspaceServerCapabilities; +import org.junit.jupiter.api.Test; + +class FolderOperationParticipantsTest extends AbstractTestWithProject { + + @Test + void testFolderFilterGlobMatching() throws Exception { + // Reconfigure with a folder-only glob filter + MockLanguageServer.reset(() -> { + ServerCapabilities caps = MockLanguageServer.defaultServerCapabilities(); + var ws = new WorkspaceServerCapabilities(); + var fileOps = new FileOperationsServerCapabilities(); + var pattern = new FileOperationPattern("**/foo"); + pattern.setMatches(FileOperationPatternKind.Folder); + var filter = new FileOperationFilter(pattern, "file"); + var opts = new FileOperationOptions(List.of(filter)); + fileOps.setWillRename(opts); + ws.setFileOperations(fileOps); + caps.setWorkspace(ws); + return caps; + }); + + // Start LS with updated capabilities + IFile starter = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(starter); + TestUtils.waitForAndAssertCondition(5_000, () -> LanguageServers.forProject(project).anyMatching()); + + // Create a folder named 'foo' + IFolder folder = project.getFolder("foo"); + if (!folder.exists()) { + folder.create(true, true, null); + } + + var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(folder, + FileOperationsServerCapabilities::getWillRename); + assertTrue(!servers.isEmpty()); + } + + @Test + void testFolderWillRename() throws Exception { + // Enable unfiltered file ops + MockLanguageServer.reset(() -> { + ServerCapabilities caps = MockLanguageServer.defaultServerCapabilities(); + var ws = new WorkspaceServerCapabilities(); + var fileOps = new FileOperationsServerCapabilities(); + fileOps.setWillRename(new FileOperationOptions()); + ws.setFileOperations(fileOps); + caps.setWorkspace(ws); + return caps; + }); + + // Start LS with updated capabilities + IFile starter = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(starter); + TestUtils.waitForAndAssertCondition(5_000, () -> LanguageServers.forProject(project).anyMatching()); + + IFolder folder = project.getFolder("oldDir"); + if (!folder.exists()) { + folder.create(true, true, null); + } + URI oldUri = LSPEclipseUtils.toUri(folder); + assertNotNull(oldUri); + + var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(folder, + FileOperationsServerCapabilities::getWillRename); + assertTrue(!servers.isEmpty()); + + var params = new RenameFilesParams(); + URI newUri = LSPEclipseUtils.toUri(project.getFolder("newDir")); + params.getFiles().add(new FileRename(oldUri.toString(), newUri.toString())); + + LSPFileOperationParticipantSupport.computePreChange("rename-folder", params, servers, + (ws, p) -> ws.willRenameFiles(p)); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillRename()); + assertEquals(1, ws.getLastWillRename().getFiles().size()); + assertEquals(oldUri.toString(), ws.getLastWillRename().getFiles().get(0).getOldUri()); + assertEquals(newUri.toString(), ws.getLastWillRename().getFiles().get(0).getNewUri()); + } + + @Test + void testFolderWillCreate() throws Exception { + MockLanguageServer.reset(() -> { + ServerCapabilities caps = MockLanguageServer.defaultServerCapabilities(); + var ws = new WorkspaceServerCapabilities(); + var fileOps = new FileOperationsServerCapabilities(); + fileOps.setWillCreate(new FileOperationOptions()); + ws.setFileOperations(fileOps); + caps.setWorkspace(ws); + return caps; + }); + + IFile starter = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(starter); + TestUtils.waitForAndAssertCondition(5_000, () -> LanguageServers.forProject(project).anyMatching()); + + IFolder folder = project.getFolder("toCreate"); + URI uri = LSPEclipseUtils.toUri(folder); + assertNotNull(uri); + + var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(folder, + FileOperationsServerCapabilities::getWillCreate); + assertTrue(!servers.isEmpty()); + + var params = new CreateFilesParams(); + params.getFiles().add(new FileCreate(uri.toString())); + + LSPFileOperationParticipantSupport.computePreChange("create-folder", params, servers, + (ws, p) -> ws.willCreateFiles(p)); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillCreate()); + assertEquals(1, ws.getLastWillCreate().getFiles().size()); + assertEquals(uri.toString(), ws.getLastWillCreate().getFiles().get(0).getUri()); + } + + @Test + void testFolderWillDelete() throws Exception { + MockLanguageServer.reset(() -> { + ServerCapabilities caps = MockLanguageServer.defaultServerCapabilities(); + var ws = new WorkspaceServerCapabilities(); + var fileOps = new FileOperationsServerCapabilities(); + fileOps.setWillDelete(new FileOperationOptions()); + ws.setFileOperations(fileOps); + caps.setWorkspace(ws); + return caps; + }); + + IFile starter = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(starter); + TestUtils.waitForAndAssertCondition(5_000, () -> LanguageServers.forProject(project).anyMatching()); + + IFolder folder = project.getFolder("toDelete"); + if (!folder.exists()) { + folder.create(true, true, null); + } + URI uri = LSPEclipseUtils.toUri(folder); + assertNotNull(uri); + + var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(folder, + FileOperationsServerCapabilities::getWillDelete); + assertTrue(!servers.isEmpty()); + + var params = new DeleteFilesParams(); + params.getFiles().add(new FileDelete(uri.toString())); + + LSPFileOperationParticipantSupport.computePreChange("delete-folder", params, servers, + (ws, p) -> ws.willDeleteFiles(p)); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillDelete()); + assertEquals(1, ws.getLastWillDelete().getFiles().size()); + assertEquals(uri.toString(), ws.getLastWillDelete().getFiles().get(0).getUri()); + } +} diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPCreateParticipantTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPCreateParticipantTest.java new file mode 100644 index 000000000..a842e78fe --- /dev/null +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPCreateParticipantTest.java @@ -0,0 +1,115 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.test.operations.rename; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.operations.rename.LSPCreateParticipant; +import org.eclipse.lsp4e.test.utils.AbstractTestWithProject; +import org.eclipse.lsp4e.test.utils.TestUtils; +import org.eclipse.lsp4e.tests.mock.MockLanguageServer; +import org.eclipse.lsp4e.tests.mock.MockWorkspaceService; +import org.eclipse.lsp4j.FileOperationOptions; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.WorkspaceServerCapabilities; +import org.eclipse.ltk.core.refactoring.participants.CheckConditionsContext; +import org.eclipse.ltk.core.refactoring.participants.CreateArguments; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LSPCreateParticipantTest extends AbstractTestWithProject { + + static class TestableCreateParticipant extends LSPCreateParticipant { + private CreateArguments args; + + void setArgs(CreateArguments args) { + this.args = args; + } + + @Override + public boolean initialize(Object element) { + return super.initialize(element); + } + + @Override + public CreateArguments getArguments() { + return args; + } + } + + @BeforeEach + void setupCaps() { + MockLanguageServer.reset(() -> { + ServerCapabilities caps = MockLanguageServer.defaultServerCapabilities(); + var ws = new WorkspaceServerCapabilities(); + var fileOps = new FileOperationsServerCapabilities(); + fileOps.setWillCreate(new FileOperationOptions()); + ws.setFileOperations(fileOps); + caps.setWorkspace(ws); + return caps; + }); + } + + @Test + void fileCreateSendsWillCreate() throws Exception { + // start LS + IFile starter = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(starter); + assertTrue(LanguageServers.forProject(project).anyMatching()); + + IFile toCreate = project.getFile("toCreate.lspt"); + URI uri = LSPEclipseUtils.toUri(toCreate); + assertNotNull(uri); + + var participant = new TestableCreateParticipant(); + participant.setArgs(new CreateArguments()); + assertTrue(participant.initialize(toCreate)); + participant.checkConditions(new NullProgressMonitor(), new CheckConditionsContext()); + participant.createPreChange(new NullProgressMonitor()); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillCreate()); + assertEquals(1, ws.getLastWillCreate().getFiles().size()); + assertEquals(uri.toString(), ws.getLastWillCreate().getFiles().get(0).getUri()); + } + + @Test + void folderCreateSendsWillCreate() throws Exception { + // start LS + IFile starter = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(starter); + assertTrue(LanguageServers.forProject(project).anyMatching()); + + IFolder toCreate = project.getFolder("toCreateFolder"); + URI uri = LSPEclipseUtils.toUri(toCreate); + assertNotNull(uri); + + var participant = new TestableCreateParticipant(); + participant.setArgs(new CreateArguments()); + assertTrue(participant.initialize(toCreate)); + participant.checkConditions(new NullProgressMonitor(), new CheckConditionsContext()); + participant.createPreChange(new NullProgressMonitor()); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillCreate()); + assertEquals(1, ws.getLastWillCreate().getFiles().size()); + assertEquals(uri.toString(), ws.getLastWillCreate().getFiles().get(0).getUri()); + } +} diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPDeleteParticipantTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPDeleteParticipantTest.java new file mode 100644 index 000000000..1e9a81c94 --- /dev/null +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPDeleteParticipantTest.java @@ -0,0 +1,116 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.test.operations.rename; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.operations.rename.LSPDeleteParticipant; +import org.eclipse.lsp4e.test.utils.AbstractTestWithProject; +import org.eclipse.lsp4e.test.utils.TestUtils; +import org.eclipse.lsp4e.tests.mock.MockLanguageServer; +import org.eclipse.lsp4e.tests.mock.MockWorkspaceService; +import org.eclipse.lsp4j.FileOperationOptions; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.WorkspaceServerCapabilities; +import org.eclipse.ltk.core.refactoring.participants.CheckConditionsContext; +import org.eclipse.ltk.core.refactoring.participants.DeleteArguments; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LSPDeleteParticipantTest extends AbstractTestWithProject { + + static class TestableDeleteParticipant extends LSPDeleteParticipant { + private DeleteArguments args; + + void setArgs(DeleteArguments args) { + this.args = args; + } + + @Override + public boolean initialize(Object element) { + return super.initialize(element); + } + + @Override + public DeleteArguments getArguments() { + return args; + } + } + + @BeforeEach + void setupCaps() { + MockLanguageServer.reset(() -> { + ServerCapabilities caps = MockLanguageServer.defaultServerCapabilities(); + var ws = new WorkspaceServerCapabilities(); + var fileOps = new FileOperationsServerCapabilities(); + fileOps.setWillDelete(new FileOperationOptions()); + ws.setFileOperations(fileOps); + caps.setWorkspace(ws); + return caps; + }); + } + + @Test + void fileDeleteSendsWillDelete() throws Exception { + IFile file = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(file); // start LS + assertTrue(LanguageServers.forProject(project).anyMatching()); + + URI uri = LSPEclipseUtils.toUri(file); + assertNotNull(uri); + + var participant = new TestableDeleteParticipant(); + participant.setArgs(new DeleteArguments()); + assertTrue(participant.initialize(file)); + participant.checkConditions(new NullProgressMonitor(), new CheckConditionsContext()); + participant.createPreChange(new NullProgressMonitor()); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillDelete()); + assertEquals(1, ws.getLastWillDelete().getFiles().size()); + assertEquals(uri.toString(), ws.getLastWillDelete().getFiles().get(0).getUri()); + } + + @Test + void folderDeleteSendsWillDelete() throws Exception { + // Start LS with a file + IFile starter = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(starter); + assertTrue(LanguageServers.forProject(project).anyMatching()); + + IFolder folder = project.getFolder("toDeleteFolder"); + if (!folder.exists()) { + folder.create(true, true, null); + } + URI uri = LSPEclipseUtils.toUri(folder); + assertNotNull(uri); + + var participant = new TestableDeleteParticipant(); + participant.setArgs(new DeleteArguments()); + assertTrue(participant.initialize(folder)); + participant.checkConditions(new NullProgressMonitor(), new CheckConditionsContext()); + participant.createPreChange(new NullProgressMonitor()); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillDelete()); + assertEquals(1, ws.getLastWillDelete().getFiles().size()); + assertEquals(uri.toString(), ws.getLastWillDelete().getFiles().get(0).getUri()); + } +} diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPMoveParticipantTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPMoveParticipantTest.java new file mode 100644 index 000000000..d8fe42b22 --- /dev/null +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPMoveParticipantTest.java @@ -0,0 +1,127 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.test.operations.rename; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.operations.rename.LSPMoveParticipant; +import org.eclipse.lsp4e.test.utils.AbstractTestWithProject; +import org.eclipse.lsp4e.test.utils.TestUtils; +import org.eclipse.lsp4e.tests.mock.MockLanguageServer; +import org.eclipse.lsp4e.tests.mock.MockWorkspaceService; +import org.eclipse.lsp4j.FileOperationOptions; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.WorkspaceServerCapabilities; +import org.eclipse.ltk.core.refactoring.participants.MoveArguments; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LSPMoveParticipantTest extends AbstractTestWithProject { + + static class TestableMoveParticipant extends LSPMoveParticipant { + private MoveArguments args; + + void setArgs(MoveArguments args) { + this.args = args; + } + + @Override + public boolean initialize(Object element) { + return super.initialize(element); + } + + @Override + public MoveArguments getArguments() { + return args; + } + } + + @BeforeEach + void setupCaps() { + MockLanguageServer.reset(() -> { + ServerCapabilities caps = MockLanguageServer.defaultServerCapabilities(); + var ws = new WorkspaceServerCapabilities(); + var fileOps = new FileOperationsServerCapabilities(); + fileOps.setWillRename(new FileOperationOptions()); + ws.setFileOperations(fileOps); + caps.setWorkspace(ws); + return caps; + }); + } + + @Test + void computesNewUriFromDestinationResource() throws Exception { + IFile file = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(file); // start LS + assertTrue(LanguageServers.forProject(project).anyMatching()); + + IFolder dest = project.getFolder("dest"); + if (!dest.exists()) { + dest.create(true, true, null); + } + + URI oldUri = LSPEclipseUtils.toUri(file); + assertNotNull(oldUri); + URI expectedNewUri = LSPEclipseUtils.toUri(dest.getRawLocation() != null // + ? dest.getRawLocation().append(file.getName()) + : dest.getLocation().append(file.getName())); + + var participant = new TestableMoveParticipant(); + participant.setArgs(new MoveArguments(dest, false)); + assertTrue(participant.initialize(file)); + participant.createPreChange(new NullProgressMonitor()); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillRename()); + assertEquals(1, ws.getLastWillRename().getFiles().size()); + assertEquals(oldUri.toString(), ws.getLastWillRename().getFiles().get(0).getOldUri()); + assertEquals(expectedNewUri.toString(), ws.getLastWillRename().getFiles().get(0).getNewUri()); + } + + @Test + void computesNewUriFromDestinationPath() throws Exception { + IFile file = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(file); // start LS + assertTrue(LanguageServers.forProject(project).anyMatching()); + + IFolder dest = project.getFolder("destPath"); + if (!dest.exists()) { + dest.create(true, true, null); + } + + URI oldUri = LSPEclipseUtils.toUri(file); + assertNotNull(oldUri); + IPath destPath = dest.getRawLocation() != null ? dest.getRawLocation() : dest.getLocation(); + assertNotNull(destPath); + URI expectedNewUri = LSPEclipseUtils.toUri(destPath.append(file.getName())); + + var participant = new TestableMoveParticipant(); + participant.setArgs(new MoveArguments(destPath, false)); + assertTrue(participant.initialize(file)); + participant.createPreChange(new NullProgressMonitor()); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillRename()); + assertEquals(1, ws.getLastWillRename().getFiles().size()); + assertEquals(oldUri.toString(), ws.getLastWillRename().getFiles().get(0).getOldUri()); + assertEquals(expectedNewUri.toString(), ws.getLastWillRename().getFiles().get(0).getNewUri()); + } +} diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPRenameParticipantTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPRenameParticipantTest.java new file mode 100644 index 000000000..2676df900 --- /dev/null +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/LSPRenameParticipantTest.java @@ -0,0 +1,139 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.test.operations.rename; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; + +import org.eclipse.core.resources.IContainer; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.operations.rename.LSPRenameParticipant; +import org.eclipse.lsp4e.test.utils.AbstractTestWithProject; +import org.eclipse.lsp4e.test.utils.TestUtils; +import org.eclipse.lsp4e.tests.mock.MockLanguageServer; +import org.eclipse.lsp4e.tests.mock.MockWorkspaceService; +import org.eclipse.lsp4j.FileOperationOptions; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.WorkspaceServerCapabilities; +import org.eclipse.ltk.core.refactoring.participants.RenameArguments; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LSPRenameParticipantTest extends AbstractTestWithProject { + + static class TestableRenameParticipant extends LSPRenameParticipant { + private RenameArguments args; + + void setArgs(RenameArguments args) { + this.args = args; + } + + @Override + public boolean initialize(Object element) { + return super.initialize(element); + } + + @Override + public RenameArguments getArguments() { + return args; + } + } + + @BeforeEach + void setupCaps() { + MockLanguageServer.reset(() -> { + ServerCapabilities caps = MockLanguageServer.defaultServerCapabilities(); + var ws = new WorkspaceServerCapabilities(); + var fileOps = new FileOperationsServerCapabilities(); + fileOps.setWillRename(new FileOperationOptions()); + ws.setFileOperations(fileOps); + caps.setWorkspace(ws); + return caps; + }); + } + + @Test + void computesNewUriFromNewName() throws Exception { + IFile file = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(file); // start LS + assertTrue(LanguageServers.forProject(project).anyMatching()); + + String newName = "renamed-" + file.getName(); + + URI oldUri = LSPEclipseUtils.toUri(file); + assertNotNull(oldUri); + + IContainer parent = file.getParent(); + IPath parentLoc = parent.getRawLocation(); + if (parentLoc == null) { + parentLoc = parent.getLocation(); + } + assertNotNull(parentLoc); + URI expectedNewUri = LSPEclipseUtils.toUri(parentLoc.append(newName)); + + var participant = new TestableRenameParticipant(); + participant.setArgs(new RenameArguments(newName, false)); + assertTrue(participant.initialize(file)); + participant.createPreChange(new NullProgressMonitor()); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillRename()); + assertEquals(1, ws.getLastWillRename().getFiles().size()); + assertEquals(oldUri.toString(), ws.getLastWillRename().getFiles().get(0).getOldUri()); + assertEquals(expectedNewUri.toString(), ws.getLastWillRename().getFiles().get(0).getNewUri()); + } + + @Test + void computesNewUriForFolderFromNewName() throws Exception { + // Start LS + IFile starter = TestUtils.createUniqueTestFile(project, "content"); + TestUtils.openTextViewer(starter); + assertTrue(LanguageServers.forProject(project).anyMatching()); + + // Prepare folder to rename + IFolder folder = project.getFolder("oldFolder"); + if (!folder.exists()) { + folder.create(true, true, null); + } + + String newName = "newFolder"; + + URI oldUri = LSPEclipseUtils.toUri(folder); + assertNotNull(oldUri); + + IContainer parent = folder.getParent(); + IPath parentLoc = parent.getRawLocation(); + if (parentLoc == null) { + parentLoc = parent.getLocation(); + } + assertNotNull(parentLoc); + URI expectedNewUri = LSPEclipseUtils.toUri(parentLoc.append(newName)); + + var participant = new TestableRenameParticipant(); + participant.setArgs(new RenameArguments(newName, false)); + assertTrue(participant.initialize(folder)); + participant.createPreChange(new NullProgressMonitor()); + + MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); + assertNotNull(ws.getLastWillRename()); + assertEquals(1, ws.getLastWillRename().getFiles().size()); + assertEquals(oldUri.toString(), ws.getLastWillRename().getFiles().get(0).getOldUri()); + assertEquals(expectedNewUri.toString(), ws.getLastWillRename().getFiles().get(0).getNewUri()); + } +} diff --git a/org.eclipse.lsp4e.tests.mock/META-INF/MANIFEST.MF b/org.eclipse.lsp4e.tests.mock/META-INF/MANIFEST.MF index 283c40591..3bc124fcc 100644 --- a/org.eclipse.lsp4e.tests.mock/META-INF/MANIFEST.MF +++ b/org.eclipse.lsp4e.tests.mock/META-INF/MANIFEST.MF @@ -2,7 +2,7 @@ Manifest-Version: 1.0 Bundle-ManifestVersion: 2 Bundle-Name: Mock Language Server to test LSP4E Bundle-SymbolicName: org.eclipse.lsp4e.tests.mock -Bundle-Version: 0.17.1.qualifier +Bundle-Version: 0.17.2.qualifier Bundle-Vendor: Eclipse LSP4E Bundle-RequiredExecutionEnvironment: JavaSE-21 Require-Bundle: org.eclipse.lsp4j, diff --git a/org.eclipse.lsp4e.tests.mock/src/org/eclipse/lsp4e/tests/mock/MockWorkspaceService.java b/org.eclipse.lsp4e.tests.mock/src/org/eclipse/lsp4e/tests/mock/MockWorkspaceService.java index 798a26230..d7d77889c 100644 --- a/org.eclipse.lsp4e.tests.mock/src/org/eclipse/lsp4e/tests/mock/MockWorkspaceService.java +++ b/org.eclipse.lsp4e.tests.mock/src/org/eclipse/lsp4e/tests/mock/MockWorkspaceService.java @@ -17,10 +17,13 @@ import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import org.eclipse.lsp4j.CreateFilesParams; import org.eclipse.lsp4j.DidChangeConfigurationParams; import org.eclipse.lsp4j.DidChangeWatchedFilesParams; import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams; +import org.eclipse.lsp4j.DeleteFilesParams; import org.eclipse.lsp4j.ExecuteCommandParams; +import org.eclipse.lsp4j.RenameFilesParams; import org.eclipse.lsp4j.SymbolInformation; import org.eclipse.lsp4j.WorkspaceSymbol; import org.eclipse.lsp4j.WorkspaceSymbolParams; @@ -34,6 +37,11 @@ public class MockWorkspaceService implements WorkspaceService { private List workspaceFoldersEvents = new ArrayList<>(); private List watchedFilesEvents = new ArrayList<>(); + // Track file operation requests for assertions in tests + private volatile CreateFilesParams lastWillCreate; + private volatile RenameFilesParams lastWillRename; + private volatile DeleteFilesParams lastWillDelete; + public MockWorkspaceService(Function> futureFactory) { this._futureFactory = futureFactory; } @@ -89,4 +97,35 @@ public CompletableFuture executeCommand(ExecuteCommandParams params) { public CompletableFuture getExecutedCommand() { return executedCommand; } + + // --- File operations --- + @Override + public CompletableFuture willCreateFiles(CreateFilesParams params) { + this.lastWillCreate = params; + return futureFactory(new org.eclipse.lsp4j.WorkspaceEdit()); + } + + @Override + public CompletableFuture willRenameFiles(RenameFilesParams params) { + this.lastWillRename = params; + return futureFactory(new org.eclipse.lsp4j.WorkspaceEdit()); + } + + @Override + public CompletableFuture willDeleteFiles(DeleteFilesParams params) { + this.lastWillDelete = params; + return futureFactory(new org.eclipse.lsp4j.WorkspaceEdit()); + } + + public CreateFilesParams getLastWillCreate() { + return lastWillCreate; + } + + public RenameFilesParams getLastWillRename() { + return lastWillRename; + } + + public DeleteFilesParams getLastWillDelete() { + return lastWillDelete; + } } diff --git a/org.eclipse.lsp4e/plugin.xml b/org.eclipse.lsp4e/plugin.xml index 9e1f2dbf6..703d482ab 100644 --- a/org.eclipse.lsp4e/plugin.xml +++ b/org.eclipse.lsp4e/plugin.xml @@ -462,6 +462,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/DocumentContentSynchronizer.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/DocumentContentSynchronizer.java index b2d53f6cc..22c977712 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/DocumentContentSynchronizer.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/DocumentContentSynchronizer.java @@ -357,11 +357,19 @@ public void documentSaved(IFileBuffer buffer) { } public void documentClosed() { - final var identifier = LSPEclipseUtils.toTextDocumentIdentifier(fileUri); + final var identifier = LSPEclipseUtils.toTextDocumentIdentifier(fileUri); WILL_SAVE_WAIT_UNTIL_TIMEOUT_MAP.remove(identifier.getUri()); // When LS is shut down all documents are being disconnected. No need to send // "didClose" message to the LS that is being shut down or not yet started if (languageServerWrapper.isActive()) { + // Ensure any pending textDocument/didChange is sent before didClose + // to preserve LSP event ordering during rename/move flows. + final var pendingChange = this.changeParams; + if (pendingChange != null) { + this.changeParams = null; + pendingChange.getTextDocument().setVersion(++version); + languageServerWrapper.sendNotification(ls -> ls.getTextDocumentService().didChange(pendingChange)); + } final var params = new DidCloseTextDocumentParams(identifier); languageServerWrapper.sendNotification(ls -> ls.getTextDocumentService().didClose(params)); } diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/SupportedFeatures.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/SupportedFeatures.java index 21122127e..f674b4eed 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/SupportedFeatures.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/SupportedFeatures.java @@ -35,6 +35,7 @@ import org.eclipse.lsp4j.DocumentSymbolCapabilities; import org.eclipse.lsp4j.ExecuteCommandCapabilities; import org.eclipse.lsp4j.FailureHandlingKind; +import org.eclipse.lsp4j.FileOperationsWorkspaceCapabilities; import org.eclipse.lsp4j.FoldingRangeCapabilities; import org.eclipse.lsp4j.FoldingRangeKind; import org.eclipse.lsp4j.FoldingRangeKindSupportCapabilities; @@ -124,10 +125,9 @@ public static TextDocumentClientCapabilities getTextDocumentClientCapabilities() foldingRangeCapabilities.setLineFoldingOnly(true); foldingRangeCapabilities.setFoldingRange(new FoldingRangeSupportCapabilities(false)); foldingRangeCapabilities.setFoldingRangeKind(new FoldingRangeKindSupportCapabilities(List.of( // - FoldingRangeKind.Comment, - FoldingRangeKind.Imports, - FoldingRangeKind.Region - ))); + FoldingRangeKind.Comment, // + FoldingRangeKind.Imports, // + FoldingRangeKind.Region))); textDocumentClientCapabilities.setFoldingRange(foldingRangeCapabilities); textDocumentClientCapabilities.setFormatting(new FormattingCapabilities(true)); final var hoverCapabilities = new HoverCapabilities(); @@ -155,6 +155,7 @@ public static WorkspaceClientCapabilities getWorkspaceClientCapabilities() { workspaceClientCapabilities.setExecuteCommand(new ExecuteCommandCapabilities(true)); workspaceClientCapabilities.setSymbol(new SymbolCapabilities(true)); workspaceClientCapabilities.setWorkspaceFolders(true); + final var editCapabilities = new WorkspaceEditCapabilities(); editCapabilities.setDocumentChanges(true); editCapabilities.setResourceOperations(List.of( // @@ -164,13 +165,20 @@ public static WorkspaceClientCapabilities getWorkspaceClientCapabilities() { editCapabilities.setFailureHandling(FailureHandlingKind.Undo); editCapabilities.setChangeAnnotationSupport(new WorkspaceEditChangeAnnotationSupportCapabilities(true)); workspaceClientCapabilities.setWorkspaceEdit(editCapabilities); + final var codeLensWorkspaceCapabilities = new CodeLensWorkspaceCapabilities(true); workspaceClientCapabilities.setCodeLens(codeLensWorkspaceCapabilities); - DidChangeWatchedFilesCapabilities didChangeWatchedFilesCapabilities = new DidChangeWatchedFilesCapabilities(true); + final var didChangeWatchedFilesCapabilities = new DidChangeWatchedFilesCapabilities(true); didChangeWatchedFilesCapabilities.setRelativePatternSupport(true); workspaceClientCapabilities.setDidChangeWatchedFiles(didChangeWatchedFilesCapabilities); + final var fileOperationsWorkspaceCapabilities = new FileOperationsWorkspaceCapabilities(); + fileOperationsWorkspaceCapabilities.setWillCreate(true); + fileOperationsWorkspaceCapabilities.setWillDelete(true); + fileOperationsWorkspaceCapabilities.setWillRename(true); + workspaceClientCapabilities.setFileOperations(fileOperationsWorkspaceCapabilities); + return workspaceClientCapabilities; } diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPCreateParticipant.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPCreateParticipant.java new file mode 100644 index 000000000..88544a442 --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPCreateParticipant.java @@ -0,0 +1,83 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.operations.rename; + +import static org.eclipse.lsp4e.internal.NullSafetyHelper.*; + +import java.net.URI; +import java.util.List; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServerWrapper; +import org.eclipse.lsp4j.CreateFilesParams; +import org.eclipse.lsp4j.FileCreate; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.participants.CheckConditionsContext; +import org.eclipse.ltk.core.refactoring.participants.CreateParticipant; + +public class LSPCreateParticipant extends CreateParticipant { + + private URI newURI = lateNonNull(); + private List servers = lateNonNull(); + + @Override + public String getName() { + return "LSP4E Create"; //$NON-NLS-1$ + } + + @Override + protected boolean initialize(final Object element) { + if (element instanceof IFile || element instanceof IFolder) { + final var res = (IResource) element; + final URI uri = LSPEclipseUtils.toUri(res); + if (uri == null) + return false; + newURI = uri; + + servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(res, + FileOperationsServerCapabilities::getWillCreate); + return !servers.isEmpty(); + } + + return false; + } + + @Override + public RefactoringStatus checkConditions(final IProgressMonitor monitor, final CheckConditionsContext context) + throws OperationCanceledException { + return new RefactoringStatus(); + } + + @Override + public @Nullable Change createChange(final IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + return null; + } + + @Override + public @Nullable Change createPreChange(final IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + final var params = new CreateFilesParams(); + params.getFiles().add(new FileCreate(newURI.toString())); + return LSPFileOperationParticipantSupport.computePreChange(getName(), params, servers, + (ws, p) -> ws.willCreateFiles(p)); + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPDeleteParticipant.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPDeleteParticipant.java new file mode 100644 index 000000000..343aca40d --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPDeleteParticipant.java @@ -0,0 +1,81 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.operations.rename; + +import static org.eclipse.lsp4e.internal.NullSafetyHelper.*; + +import java.net.URI; +import java.util.List; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServerWrapper; +import org.eclipse.lsp4j.DeleteFilesParams; +import org.eclipse.lsp4j.FileDelete; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.participants.CheckConditionsContext; +import org.eclipse.ltk.core.refactoring.participants.DeleteParticipant; + +public class LSPDeleteParticipant extends DeleteParticipant { + + private URI oldURI = lateNonNull(); + private List servers = lateNonNull(); + + @Override + public String getName() { + return "LSP4E Delete"; //$NON-NLS-1$ + } + + @Override + protected boolean initialize(final Object element) { + if (element instanceof IFile || element instanceof IFolder) { + final var res = (IResource) element; + final URI uri = LSPEclipseUtils.toUri(res); + if (uri == null) + return false; + oldURI = uri; + servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(res, + FileOperationsServerCapabilities::getWillDelete); + return !servers.isEmpty(); + } + return false; + } + + @Override + public RefactoringStatus checkConditions(final IProgressMonitor monitor, final CheckConditionsContext context) + throws OperationCanceledException { + return new RefactoringStatus(); + } + + @Override + public @Nullable Change createChange(final IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + return null; + } + + @Override + public @Nullable Change createPreChange(final IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + final var params = new DeleteFilesParams(); + params.getFiles().add(new FileDelete(oldURI.toString())); + return LSPFileOperationParticipantSupport.computePreChange(getName(), params, servers, + (ws, p) -> ws.willDeleteFiles(p)); + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java new file mode 100644 index 000000000..7e5877531 --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java @@ -0,0 +1,130 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.operations.rename; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.BiFunction; +import java.util.function.Function; + +import org.eclipse.core.resources.IResource; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServerPlugin; +import org.eclipse.lsp4e.LanguageServerWrapper; +import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.internal.files.PathPatternMatcher; +import org.eclipse.lsp4j.FileOperationFilter; +import org.eclipse.lsp4j.FileOperationOptions; +import org.eclipse.lsp4j.FileOperationPatternKind; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.lsp4j.WorkspaceEdit; +import org.eclipse.lsp4j.services.WorkspaceService; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.CompositeChange; + +public final class LSPFileOperationParticipantSupport { + + /** + * Hard timeout for willCreate/willRename/willDelete requests to avoid UI hangs + * when a server advertises support but never responds. + */ + private static final long FILE_OP_TIMEOUT_SECONDS = 10; + + public static

@Nullable Change computePreChange(final String changeName, final P params, + final List servers, + final BiFunction> request) { + + final CompositeChange[] changes = servers.stream() // + .map(wrapper -> wrapper.execute(ls -> request // + .apply(ls.getWorkspaceService(), params)) // + .orTimeout(FILE_OP_TIMEOUT_SECONDS, TimeUnit.SECONDS).exceptionally(ex -> { + LanguageServerPlugin.logWarning( + "File operation pre-change '" + changeName + "' failed or timed out for server: " //$NON-NLS-1$ //$NON-NLS-2$ + + wrapper.serverDefinition.label, + ex); + return null; + }).thenApply(edits -> edits == null || isEmptyEdit(edits) // + ? (@Nullable CompositeChange) null + : LSPEclipseUtils.toCompositeChange(edits, wrapper.serverDefinition.label))) // + .map(CompletableFuture::join) // + .filter(Objects::nonNull) // + .toArray(CompositeChange[]::new); + + return switch (changes.length) { + case 0 -> null; + case 1 -> changes[0]; + default -> new CompositeChange(changeName, changes); + }; + } + + public static List getServersWithFileOperation(final IResource res, + final Function optionsProvider) { + final var uri = LSPEclipseUtils.toUri(res); + if (uri == null) + return List.of(); + + final var path = Path.of(uri); + return LanguageServers.forProject(res.getProject()).withFilter(caps -> { + final var wks = caps.getWorkspace(); + if (wks == null) + return false; + final var fileOps = wks.getFileOperations(); + if (fileOps == null) + return false; + + final var options = optionsProvider.apply(fileOps); + return matches(options, path, res.getType() == IResource.FOLDER); + }).collectAll((wrapper, ls) -> CompletableFuture.completedFuture(wrapper)).join(); + } + + private static boolean isEmptyEdit(final WorkspaceEdit edits) { + return (edits.getChanges() == null || edits.getChanges().isEmpty()) + && (edits.getDocumentChanges() == null || edits.getDocumentChanges().isEmpty()); + } + + private static boolean matches(final @Nullable FileOperationOptions options, final Path path, + final boolean isFolder) { + if (options == null) + return false; + + final var filters = options.getFilters(); + return filters.isEmpty() || filters.stream().anyMatch(filter -> matchesFilter(filter, path, isFolder)); + } + + private static boolean matchesFilter(final FileOperationFilter filter, final Path path, final boolean isFolder) { + final var scheme = filter.getScheme(); + if (scheme != null && !"file".equalsIgnoreCase(scheme)) //$NON-NLS-1$ + return false; + + final var pattern = filter.getPattern(); + if (pattern.getGlob().isBlank()) + return false; + + final var matches = pattern.getMatches(); + if (FileOperationPatternKind.File.equals(matches) && isFolder) + return false; + + if (FileOperationPatternKind.Folder.equals(matches) && !isFolder) + return false; + + final String glob = pattern.getGlob(); + final var matcher = new PathPatternMatcher(glob, null); + return matcher.matches(path); + } + + private LSPFileOperationParticipantSupport() { + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPMoveParticipant.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPMoveParticipant.java new file mode 100644 index 000000000..ecfb31edb --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPMoveParticipant.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.operations.rename; + +import static org.eclipse.lsp4e.internal.NullSafetyHelper.*; + +import java.net.URI; +import java.util.List; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServerWrapper; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.lsp4j.FileRename; +import org.eclipse.lsp4j.RenameFilesParams; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.participants.CheckConditionsContext; +import org.eclipse.ltk.core.refactoring.participants.MoveParticipant; + +public class LSPMoveParticipant extends MoveParticipant { + + private URI oldURI = lateNonNull(); + private URI newURI = lateNonNull(); + private List servers = lateNonNull(); + + @Override + public String getName() { + return "LSP4E Move"; //$NON-NLS-1$ + } + + @Override + protected boolean initialize(final Object element) { + if (element instanceof IFile || element instanceof IFolder) { + final var res = (IResource) element; + final URI uri = LSPEclipseUtils.toUri(res); + if (uri == null) + return false; + oldURI = uri; + + // Compute destination from MoveArguments destination (container path) + final Object dest = getArguments().getDestination(); + IPath destLoc = null; + if (dest instanceof IResource destRes) { + destLoc = destRes.getRawLocation(); + } else if (dest instanceof IPath destPath) { + destLoc = destPath; + } + if (destLoc == null) + return false; + + final String targetName = res.getName(); + newURI = LSPEclipseUtils.toUri(destLoc.append(targetName)); + + servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(res, + FileOperationsServerCapabilities::getWillRename); + return !servers.isEmpty(); + } + + return false; + } + + @Override + public RefactoringStatus checkConditions(final IProgressMonitor monitor, final CheckConditionsContext context) + throws OperationCanceledException { + return new RefactoringStatus(); + } + + @Override + public @Nullable Change createChange(final IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + return null; + } + + @Override + public @Nullable Change createPreChange(final IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + final var params = new RenameFilesParams(); + params.getFiles().add(new FileRename(oldURI.toString(), newURI.toString())); + return LSPFileOperationParticipantSupport.computePreChange(getName(), params, servers, + (ws, p) -> ws.willRenameFiles(p)); + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPRenameParticipant.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPRenameParticipant.java new file mode 100644 index 000000000..029f0d484 --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPRenameParticipant.java @@ -0,0 +1,93 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.operations.rename; + +import static org.eclipse.lsp4e.internal.NullSafetyHelper.*; + +import java.net.URI; +import java.util.List; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IFolder; +import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.OperationCanceledException; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4e.LanguageServerWrapper; +import org.eclipse.lsp4j.FileOperationsServerCapabilities; +import org.eclipse.lsp4j.FileRename; +import org.eclipse.lsp4j.RenameFilesParams; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.participants.CheckConditionsContext; +import org.eclipse.ltk.core.refactoring.participants.RenameParticipant; + +public class LSPRenameParticipant extends RenameParticipant { + + private URI oldURI = lateNonNull(); + private URI newURI = lateNonNull(); + private List servers = lateNonNull(); + + @Override + public String getName() { + return "LSP4E Rename"; //$NON-NLS-1$ + } + + @Override + protected boolean initialize(final Object element) { + if (element instanceof IFile || element instanceof IFolder) { + final var res = (IResource) element; + final URI uri = LSPEclipseUtils.toUri(res); + if (uri == null) + return false; + oldURI = uri; + + IPath parentLoc = res.getParent().getRawLocation(); + if (parentLoc == null) { + parentLoc = res.getParent().getLocation(); + if (parentLoc == null) + return false; + } + newURI = LSPEclipseUtils.toUri(parentLoc.append(getArguments().getNewName())); + + servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(res, + FileOperationsServerCapabilities::getWillRename); + return !servers.isEmpty(); + } + + return false; + } + + @Override + public RefactoringStatus checkConditions(final IProgressMonitor monitor, final CheckConditionsContext context) + throws OperationCanceledException { + return new RefactoringStatus(); + } + + @Override + public @Nullable Change createChange(final IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + return null; + } + + @Override + public @Nullable Change createPreChange(final IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + final var params = new RenameFilesParams(); + params.getFiles().add(new FileRename(oldURI.toString(), newURI.toString())); + return LSPFileOperationParticipantSupport.computePreChange(getName(), params, servers, + (ws, p) -> ws.willRenameFiles(p)); + } +} From 08a8f213889f107473d32f0dbb6fa0160f320dd3 Mon Sep 17 00:00:00 2001 From: Sebastian Thomschke Date: Mon, 24 Nov 2025 11:36:40 +0100 Subject: [PATCH 2/3] fix: address PR review feedback --- .../rename/FileOperationParticipantsTest.java | 22 +++---- .../FolderOperationParticipantsTest.java | 21 +++---- .../rename/LSPCreateParticipant.java | 19 +++--- .../rename/LSPDeleteParticipant.java | 19 +++--- .../LSPFileOperationParticipantSupport.java | 62 ++++++++++++------- .../operations/rename/LSPMoveParticipant.java | 19 +++--- .../rename/LSPRenameParticipant.java | 19 +++--- 7 files changed, 91 insertions(+), 90 deletions(-) diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FileOperationParticipantsTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FileOperationParticipantsTest.java index eef51a4e5..af6121fd5 100644 --- a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FileOperationParticipantsTest.java +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FileOperationParticipantsTest.java @@ -75,9 +75,9 @@ void testFilterGlobMatching() throws Exception { IFile file = TestUtils.createUniqueTestFile(project, "content"); TestUtils.openTextViewer(file); TestUtils.waitForAndAssertCondition(5_000, () -> LanguageServers.forProject(project).anyMatching()); - var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(file, + var executor = LSPFileOperationParticipantSupport.createFileOperationExecutor(file, FileOperationsServerCapabilities::getWillRename); - assertTrue(!servers.isEmpty()); + assertTrue(executor.anyMatching()); } @Test @@ -90,16 +90,16 @@ void testWillRename() throws Exception { // Ensure an LS is available assertTrue(LanguageServers.forProject(project).anyMatching()); - var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(file, + var executor = LSPFileOperationParticipantSupport.createFileOperationExecutor(file, FileOperationsServerCapabilities::getWillRename); - assertTrue(!servers.isEmpty()); + assertTrue(executor.anyMatching()); var params = new RenameFilesParams(); URI newUri = LSPEclipseUtils.toUri(project.getFile("renamed-" + file.getName())); params.getFiles().add(new FileRename(uri.toString(), newUri.toString())); // Exercise helper to trigger server call - LSPFileOperationParticipantSupport.computePreChange("rename", params, servers, + LSPFileOperationParticipantSupport.computePreChange("rename", params, executor, (ws, p) -> ws.willRenameFiles(p)); MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); @@ -116,14 +116,14 @@ void testWillCreate() throws Exception { URI uri = LSPEclipseUtils.toUri(file); assertNotNull(uri); - var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(file, + var executor = LSPFileOperationParticipantSupport.createFileOperationExecutor(file, FileOperationsServerCapabilities::getWillCreate); - assertTrue(!servers.isEmpty()); + assertTrue(executor.anyMatching()); var params = new CreateFilesParams(); params.getFiles().add(new FileCreate(uri.toString())); - LSPFileOperationParticipantSupport.computePreChange("create", params, servers, + LSPFileOperationParticipantSupport.computePreChange("create", params, executor, (ws, p) -> ws.willCreateFiles(p)); MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); @@ -139,14 +139,14 @@ void testWillDelete() throws Exception { URI uri = LSPEclipseUtils.toUri(file); assertNotNull(uri); - var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(file, + var executor = LSPFileOperationParticipantSupport.createFileOperationExecutor(file, FileOperationsServerCapabilities::getWillDelete); - assertTrue(!servers.isEmpty()); + assertTrue(executor.anyMatching()); var params = new DeleteFilesParams(); params.getFiles().add(new FileDelete(uri.toString())); - LSPFileOperationParticipantSupport.computePreChange("delete", params, servers, + LSPFileOperationParticipantSupport.computePreChange("delete", params, executor, (ws, p) -> ws.willDeleteFiles(p)); MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FolderOperationParticipantsTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FolderOperationParticipantsTest.java index d41e1b703..148f3b3a4 100644 --- a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FolderOperationParticipantsTest.java +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/operations/rename/FolderOperationParticipantsTest.java @@ -70,9 +70,9 @@ void testFolderFilterGlobMatching() throws Exception { folder.create(true, true, null); } - var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(folder, + var executor = LSPFileOperationParticipantSupport.createFileOperationExecutor(folder, FileOperationsServerCapabilities::getWillRename); - assertTrue(!servers.isEmpty()); + assertTrue(executor.anyMatching()); } @Test @@ -100,15 +100,15 @@ void testFolderWillRename() throws Exception { URI oldUri = LSPEclipseUtils.toUri(folder); assertNotNull(oldUri); - var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(folder, + var executor = LSPFileOperationParticipantSupport.createFileOperationExecutor(folder, FileOperationsServerCapabilities::getWillRename); - assertTrue(!servers.isEmpty()); + assertTrue(executor.anyMatching()); var params = new RenameFilesParams(); URI newUri = LSPEclipseUtils.toUri(project.getFolder("newDir")); params.getFiles().add(new FileRename(oldUri.toString(), newUri.toString())); - LSPFileOperationParticipantSupport.computePreChange("rename-folder", params, servers, + LSPFileOperationParticipantSupport.computePreChange("rename-folder", params, executor, (ws, p) -> ws.willRenameFiles(p)); MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); @@ -138,14 +138,14 @@ void testFolderWillCreate() throws Exception { URI uri = LSPEclipseUtils.toUri(folder); assertNotNull(uri); - var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(folder, + var executor = LSPFileOperationParticipantSupport.createFileOperationExecutor(folder, FileOperationsServerCapabilities::getWillCreate); - assertTrue(!servers.isEmpty()); + assertTrue(executor.anyMatching()); var params = new CreateFilesParams(); params.getFiles().add(new FileCreate(uri.toString())); - LSPFileOperationParticipantSupport.computePreChange("create-folder", params, servers, + LSPFileOperationParticipantSupport.computePreChange("create-folder", params, executor, (ws, p) -> ws.willCreateFiles(p)); MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); @@ -177,14 +177,13 @@ void testFolderWillDelete() throws Exception { URI uri = LSPEclipseUtils.toUri(folder); assertNotNull(uri); - var servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(folder, + var executor = LSPFileOperationParticipantSupport.createFileOperationExecutor(folder, FileOperationsServerCapabilities::getWillDelete); - assertTrue(!servers.isEmpty()); var params = new DeleteFilesParams(); params.getFiles().add(new FileDelete(uri.toString())); - LSPFileOperationParticipantSupport.computePreChange("delete-folder", params, servers, + LSPFileOperationParticipantSupport.computePreChange("delete-folder", params, executor, (ws, p) -> ws.willDeleteFiles(p)); MockWorkspaceService ws = MockLanguageServer.INSTANCE.getWorkspaceService(); diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPCreateParticipant.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPCreateParticipant.java index 88544a442..d829bd971 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPCreateParticipant.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPCreateParticipant.java @@ -11,10 +11,9 @@ *******************************************************************************/ package org.eclipse.lsp4e.operations.rename; -import static org.eclipse.lsp4e.internal.NullSafetyHelper.*; +import static org.eclipse.lsp4e.internal.NullSafetyHelper.lateNonNull; import java.net.URI; -import java.util.List; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; @@ -24,7 +23,6 @@ import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.lsp4e.LSPEclipseUtils; -import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4j.CreateFilesParams; import org.eclipse.lsp4j.FileCreate; import org.eclipse.lsp4j.FileOperationsServerCapabilities; @@ -36,7 +34,7 @@ public class LSPCreateParticipant extends CreateParticipant { private URI newURI = lateNonNull(); - private List servers = lateNonNull(); + private IResource resource = lateNonNull(); @Override public String getName() { @@ -45,16 +43,15 @@ public String getName() { @Override protected boolean initialize(final Object element) { - if (element instanceof IFile || element instanceof IFolder) { - final var res = (IResource) element; + if (element instanceof final IResource res && (res instanceof IFile || res instanceof IFolder)) { + resource = res; final URI uri = LSPEclipseUtils.toUri(res); if (uri == null) return false; newURI = uri; - servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(res, - FileOperationsServerCapabilities::getWillCreate); - return !servers.isEmpty(); + return LSPFileOperationParticipantSupport + .createFileOperationExecutor(res, FileOperationsServerCapabilities::getWillCreate).anyMatching(); } return false; @@ -77,7 +74,7 @@ public RefactoringStatus checkConditions(final IProgressMonitor monitor, final C throws CoreException, OperationCanceledException { final var params = new CreateFilesParams(); params.getFiles().add(new FileCreate(newURI.toString())); - return LSPFileOperationParticipantSupport.computePreChange(getName(), params, servers, - (ws, p) -> ws.willCreateFiles(p)); + return LSPFileOperationParticipantSupport.computePreChange(getName(), params, resource, + FileOperationsServerCapabilities::getWillCreate, (ws, p) -> ws.willCreateFiles(p)); } } diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPDeleteParticipant.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPDeleteParticipant.java index 343aca40d..a25ce678d 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPDeleteParticipant.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPDeleteParticipant.java @@ -11,10 +11,9 @@ *******************************************************************************/ package org.eclipse.lsp4e.operations.rename; -import static org.eclipse.lsp4e.internal.NullSafetyHelper.*; +import static org.eclipse.lsp4e.internal.NullSafetyHelper.lateNonNull; import java.net.URI; -import java.util.List; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; @@ -24,7 +23,6 @@ import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.lsp4e.LSPEclipseUtils; -import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4j.DeleteFilesParams; import org.eclipse.lsp4j.FileDelete; import org.eclipse.lsp4j.FileOperationsServerCapabilities; @@ -36,7 +34,7 @@ public class LSPDeleteParticipant extends DeleteParticipant { private URI oldURI = lateNonNull(); - private List servers = lateNonNull(); + private IResource resource = lateNonNull(); @Override public String getName() { @@ -45,15 +43,14 @@ public String getName() { @Override protected boolean initialize(final Object element) { - if (element instanceof IFile || element instanceof IFolder) { - final var res = (IResource) element; + if (element instanceof final IResource res && (res instanceof IFile || res instanceof IFolder)) { + resource = res; final URI uri = LSPEclipseUtils.toUri(res); if (uri == null) return false; oldURI = uri; - servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(res, - FileOperationsServerCapabilities::getWillDelete); - return !servers.isEmpty(); + return LSPFileOperationParticipantSupport + .createFileOperationExecutor(res, FileOperationsServerCapabilities::getWillDelete).anyMatching(); } return false; } @@ -75,7 +72,7 @@ public RefactoringStatus checkConditions(final IProgressMonitor monitor, final C throws CoreException, OperationCanceledException { final var params = new DeleteFilesParams(); params.getFiles().add(new FileDelete(oldURI.toString())); - return LSPFileOperationParticipantSupport.computePreChange(getName(), params, servers, - (ws, p) -> ws.willDeleteFiles(p)); + return LSPFileOperationParticipantSupport.computePreChange(getName(), params, resource, + FileOperationsServerCapabilities::getWillDelete, (ws, p) -> ws.willDeleteFiles(p)); } } diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java index 7e5877531..fd23cbae7 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java @@ -12,7 +12,6 @@ package org.eclipse.lsp4e.operations.rename; import java.nio.file.Path; -import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -23,8 +22,8 @@ import org.eclipse.jdt.annotation.Nullable; import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4e.LanguageServerPlugin; -import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.LanguageServers.LanguageServerProjectExecutor; import org.eclipse.lsp4e.internal.files.PathPatternMatcher; import org.eclipse.lsp4j.FileOperationFilter; import org.eclipse.lsp4j.FileOperationOptions; @@ -35,6 +34,11 @@ import org.eclipse.ltk.core.refactoring.Change; import org.eclipse.ltk.core.refactoring.CompositeChange; +/** + * Internal class, only public to be accessible by test cases. + * + * @noreference + */ public final class LSPFileOperationParticipantSupport { /** @@ -44,22 +48,28 @@ public final class LSPFileOperationParticipantSupport { private static final long FILE_OP_TIMEOUT_SECONDS = 10; public static

@Nullable Change computePreChange(final String changeName, final P params, - final List servers, + final IResource resource, + final Function optionsProvider, final BiFunction> request) { + return computePreChange(changeName, params, createFileOperationExecutor(resource, optionsProvider), request); + } - final CompositeChange[] changes = servers.stream() // - .map(wrapper -> wrapper.execute(ls -> request // - .apply(ls.getWorkspaceService(), params)) // - .orTimeout(FILE_OP_TIMEOUT_SECONDS, TimeUnit.SECONDS).exceptionally(ex -> { - LanguageServerPlugin.logWarning( - "File operation pre-change '" + changeName + "' failed or timed out for server: " //$NON-NLS-1$ //$NON-NLS-2$ - + wrapper.serverDefinition.label, - ex); - return null; - }).thenApply(edits -> edits == null || isEmptyEdit(edits) // - ? (@Nullable CompositeChange) null - : LSPEclipseUtils.toCompositeChange(edits, wrapper.serverDefinition.label))) // - .map(CompletableFuture::join) // + public static

@Nullable Change computePreChange(final String changeName, final P params, + final LanguageServerProjectExecutor executor, + final BiFunction> request) { + final CompositeChange[] changes = executor.collectAll((wrapper, ls) -> request // + .apply(ls.getWorkspaceService(), params) // + .orTimeout(FILE_OP_TIMEOUT_SECONDS, TimeUnit.SECONDS).exceptionally(ex -> { + LanguageServerPlugin.logWarning( + "File operation pre-change '" + changeName + "' failed or timed out for server: " //$NON-NLS-1$ //$NON-NLS-2$ + + wrapper.serverDefinition.label, + ex); + return null; + }).thenApply(edits -> edits == null || isEmptyEdit(edits) // + ? (@Nullable CompositeChange) null + : LSPEclipseUtils.toCompositeChange(edits, wrapper.serverDefinition.label))) // + .join() // + .stream() // .filter(Objects::nonNull) // .toArray(CompositeChange[]::new); @@ -70,24 +80,28 @@ public final class LSPFileOperationParticipantSupport { }; } - public static List getServersWithFileOperation(final IResource res, + public static LanguageServerProjectExecutor createFileOperationExecutor(final IResource res, final Function optionsProvider) { final var uri = LSPEclipseUtils.toUri(res); - if (uri == null) - return List.of(); + final var project = res.getProject(); + if (uri == null) { + // No URI means we cannot match any file operation filters; return an executor + // that will not match any servers. + return LanguageServers.forProject(project).withFilter(capabilities -> false); + } final var path = Path.of(uri); - return LanguageServers.forProject(res.getProject()).withFilter(caps -> { - final var wks = caps.getWorkspace(); - if (wks == null) + return LanguageServers.forProject(project).withFilter(capabilities -> { + final var workspace = capabilities.getWorkspace(); + if (workspace == null) return false; - final var fileOps = wks.getFileOperations(); + final var fileOps = workspace.getFileOperations(); if (fileOps == null) return false; final var options = optionsProvider.apply(fileOps); return matches(options, path, res.getType() == IResource.FOLDER); - }).collectAll((wrapper, ls) -> CompletableFuture.completedFuture(wrapper)).join(); + }); } private static boolean isEmptyEdit(final WorkspaceEdit edits) { diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPMoveParticipant.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPMoveParticipant.java index ecfb31edb..c93e51bf4 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPMoveParticipant.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPMoveParticipant.java @@ -11,10 +11,9 @@ *******************************************************************************/ package org.eclipse.lsp4e.operations.rename; -import static org.eclipse.lsp4e.internal.NullSafetyHelper.*; +import static org.eclipse.lsp4e.internal.NullSafetyHelper.lateNonNull; import java.net.URI; -import java.util.List; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; @@ -25,7 +24,6 @@ import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.lsp4e.LSPEclipseUtils; -import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4j.FileOperationsServerCapabilities; import org.eclipse.lsp4j.FileRename; import org.eclipse.lsp4j.RenameFilesParams; @@ -38,7 +36,7 @@ public class LSPMoveParticipant extends MoveParticipant { private URI oldURI = lateNonNull(); private URI newURI = lateNonNull(); - private List servers = lateNonNull(); + private IResource resource = lateNonNull(); @Override public String getName() { @@ -47,8 +45,8 @@ public String getName() { @Override protected boolean initialize(final Object element) { - if (element instanceof IFile || element instanceof IFolder) { - final var res = (IResource) element; + if (element instanceof final IResource res && (res instanceof IFile || res instanceof IFolder)) { + resource = res; final URI uri = LSPEclipseUtils.toUri(res); if (uri == null) return false; @@ -68,9 +66,8 @@ protected boolean initialize(final Object element) { final String targetName = res.getName(); newURI = LSPEclipseUtils.toUri(destLoc.append(targetName)); - servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(res, - FileOperationsServerCapabilities::getWillRename); - return !servers.isEmpty(); + return LSPFileOperationParticipantSupport + .createFileOperationExecutor(res, FileOperationsServerCapabilities::getWillRename).anyMatching(); } return false; @@ -93,7 +90,7 @@ public RefactoringStatus checkConditions(final IProgressMonitor monitor, final C throws CoreException, OperationCanceledException { final var params = new RenameFilesParams(); params.getFiles().add(new FileRename(oldURI.toString(), newURI.toString())); - return LSPFileOperationParticipantSupport.computePreChange(getName(), params, servers, - (ws, p) -> ws.willRenameFiles(p)); + return LSPFileOperationParticipantSupport.computePreChange(getName(), params, resource, + FileOperationsServerCapabilities::getWillRename, (ws, p) -> ws.willRenameFiles(p)); } } diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPRenameParticipant.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPRenameParticipant.java index 029f0d484..7de5b9256 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPRenameParticipant.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPRenameParticipant.java @@ -11,10 +11,9 @@ *******************************************************************************/ package org.eclipse.lsp4e.operations.rename; -import static org.eclipse.lsp4e.internal.NullSafetyHelper.*; +import static org.eclipse.lsp4e.internal.NullSafetyHelper.lateNonNull; import java.net.URI; -import java.util.List; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; @@ -25,7 +24,6 @@ import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.lsp4e.LSPEclipseUtils; -import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4j.FileOperationsServerCapabilities; import org.eclipse.lsp4j.FileRename; import org.eclipse.lsp4j.RenameFilesParams; @@ -38,7 +36,7 @@ public class LSPRenameParticipant extends RenameParticipant { private URI oldURI = lateNonNull(); private URI newURI = lateNonNull(); - private List servers = lateNonNull(); + private IResource resource = lateNonNull(); @Override public String getName() { @@ -47,8 +45,8 @@ public String getName() { @Override protected boolean initialize(final Object element) { - if (element instanceof IFile || element instanceof IFolder) { - final var res = (IResource) element; + if (element instanceof final IResource res && (res instanceof IFile || res instanceof IFolder)) { + resource = res; final URI uri = LSPEclipseUtils.toUri(res); if (uri == null) return false; @@ -62,9 +60,8 @@ protected boolean initialize(final Object element) { } newURI = LSPEclipseUtils.toUri(parentLoc.append(getArguments().getNewName())); - servers = LSPFileOperationParticipantSupport.getServersWithFileOperation(res, - FileOperationsServerCapabilities::getWillRename); - return !servers.isEmpty(); + return LSPFileOperationParticipantSupport + .createFileOperationExecutor(res, FileOperationsServerCapabilities::getWillRename).anyMatching(); } return false; @@ -87,7 +84,7 @@ public RefactoringStatus checkConditions(final IProgressMonitor monitor, final C throws CoreException, OperationCanceledException { final var params = new RenameFilesParams(); params.getFiles().add(new FileRename(oldURI.toString(), newURI.toString())); - return LSPFileOperationParticipantSupport.computePreChange(getName(), params, servers, - (ws, p) -> ws.willRenameFiles(p)); + return LSPFileOperationParticipantSupport.computePreChange(getName(), params, resource, + FileOperationsServerCapabilities::getWillRename, (ws, p) -> ws.willRenameFiles(p)); } } From 93fd9b1bcc402059c9aa10cba817ef554c7b891a Mon Sep 17 00:00:00 2001 From: Sebastian Thomschke Date: Mon, 24 Nov 2025 16:59:34 +0100 Subject: [PATCH 3/3] fix: address PR review feedback --- .../LSPFileOperationParticipantSupport.java | 65 +++++++++++++------ 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java index fd23cbae7..2b8fdcbb3 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/rename/LSPFileOperationParticipantSupport.java @@ -12,13 +12,19 @@ package org.eclipse.lsp4e.operations.rename; import java.nio.file.Path; +import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.function.BiFunction; import java.util.function.Function; import org.eclipse.core.resources.IResource; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4e.LanguageServerPlugin; @@ -47,31 +53,52 @@ public final class LSPFileOperationParticipantSupport { */ private static final long FILE_OP_TIMEOUT_SECONDS = 10; - public static

@Nullable Change computePreChange(final String changeName, final P params, - final IResource resource, + static

@Nullable Change computePreChange(final String changeName, final P params, final IResource resource, final Function optionsProvider, - final BiFunction> request) { + final BiFunction> request) + throws CoreException { return computePreChange(changeName, params, createFileOperationExecutor(resource, optionsProvider), request); } public static

@Nullable Change computePreChange(final String changeName, final P params, final LanguageServerProjectExecutor executor, - final BiFunction> request) { - final CompositeChange[] changes = executor.collectAll((wrapper, ls) -> request // - .apply(ls.getWorkspaceService(), params) // - .orTimeout(FILE_OP_TIMEOUT_SECONDS, TimeUnit.SECONDS).exceptionally(ex -> { - LanguageServerPlugin.logWarning( - "File operation pre-change '" + changeName + "' failed or timed out for server: " //$NON-NLS-1$ //$NON-NLS-2$ - + wrapper.serverDefinition.label, - ex); - return null; - }).thenApply(edits -> edits == null || isEmptyEdit(edits) // - ? (@Nullable CompositeChange) null - : LSPEclipseUtils.toCompositeChange(edits, wrapper.serverDefinition.label))) // - .join() // - .stream() // - .filter(Objects::nonNull) // - .toArray(CompositeChange[]::new); + final BiFunction> request) + throws CoreException { + + final CompletableFuture> future = executor // + .collectAll((wrapper, ls) -> request // + .apply(ls.getWorkspaceService(), params) // + .thenApply(edits -> edits == null || isEmptyEdit(edits) // + ? (@Nullable CompositeChange) null + : LSPEclipseUtils.toCompositeChange(edits, wrapper.serverDefinition.label)) // + .orTimeout(FILE_OP_TIMEOUT_SECONDS, TimeUnit.SECONDS) // + .exceptionally(ex -> { + final String logHeader = "File operation pre-change '" + changeName; //$NON-NLS-1$ + if (ex instanceof TimeoutException) { + LanguageServerPlugin.logWarning(logHeader + "' timed out for server: " //$NON-NLS-1$ + + wrapper.serverDefinition.label + " after " + FILE_OP_TIMEOUT_SECONDS //$NON-NLS-1$ + + " seconds"); //$NON-NLS-1$ + } else { + LanguageServerPlugin.logError(logHeader + "' failed for server: " //$NON-NLS-1$ + + wrapper.serverDefinition.label, ex); + } + return null; + })); + + final CompositeChange[] changes; + try { + changes = future.get() // + .stream() // + .filter(Objects::nonNull) // + .toArray(CompositeChange[]::new); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new CoreException(new Status(IStatus.ERROR, LanguageServerPlugin.PLUGIN_ID, + "File operation pre-change '" + changeName + "' was interrupted", ex)); //$NON-NLS-1$ //$NON-NLS-2$ + } catch (final ExecutionException ex) { + throw new CoreException(new Status(IStatus.ERROR, LanguageServerPlugin.PLUGIN_ID, + "File operation pre-change '" + changeName + "' failed", ex)); //$NON-NLS-1$ //$NON-NLS-2$ + } return switch (changes.length) { case 0 -> null;