diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/commands/DynamicRegistrationTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/commands/DynamicRegistrationTest.java index df0aac27e..d89ff7141 100644 --- a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/commands/DynamicRegistrationTest.java +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/commands/DynamicRegistrationTest.java @@ -30,7 +30,10 @@ 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.DidChangeWatchedFilesParams; import org.eclipse.lsp4j.ExecuteCommandOptions; +import org.eclipse.lsp4j.FileChangeType; import org.eclipse.lsp4j.Registration; import org.eclipse.lsp4j.RegistrationParams; import org.eclipse.lsp4j.ServerCapabilities; @@ -48,6 +51,7 @@ public class DynamicRegistrationTest extends AbstractTestWithProject { private static final String WORKSPACE_EXECUTE_COMMAND = "workspace/executeCommand"; private static final String WORKSPACE_DID_CHANGE_FOLDERS = "workspace/didChangeWorkspaceFolders"; + private static final String WORKSPACE_DID_CHANGE_WATCHED_FILES = "workspace/didChangeWatchedFiles"; @BeforeEach public void setUp() throws Exception { @@ -73,12 +77,36 @@ public void testCommandRegistration() throws Exception { assertTrue(LanguageServiceAccessor.hasActiveLanguageServers(handlesCommand("test.command"))); assertTrue(LanguageServiceAccessor.hasActiveLanguageServers(handlesCommand("test.command.2"))); } finally { - unregister(registration); + unregister(registration, WORKSPACE_EXECUTE_COMMAND); } assertFalse(LanguageServiceAccessor.hasActiveLanguageServers(handlesCommand("test.command"))); assertFalse(LanguageServiceAccessor.hasActiveLanguageServers(handlesCommand("test.command.2"))); } + @Test + public void testWatchedFilesRegistrationAndNotification() throws Exception { + assertTrue(LanguageServiceAccessor.hasActiveLanguageServers(c -> true)); + + UUID registration = registerWatchedFiles(); + try { + MockWorkspaceService workspaceService = MockLanguageServer.INSTANCE.getWorkspaceService(); + + TestUtils.createFile(project, "watched.txt", ""); + TestUtils.createFile(project, "unwatched.bin", ""); + + waitForCondition(5_000, () -> !workspaceService.getWatchedFilesEvents().isEmpty()); + + DidChangeWatchedFilesParams params = workspaceService.getWatchedFilesEvents().get(0); + assertFalse(params.getChanges().isEmpty()); + assertTrue(params.getChanges().stream().anyMatch( + ev -> ev.getUri().endsWith("watched.txt") && ev.getType() == FileChangeType.Created)); + assertFalse(params.getChanges().stream() + .anyMatch(ev -> ev.getUri().endsWith("unwatched.bin"))); + } finally { + unregister(registration, WORKSPACE_DID_CHANGE_WATCHED_FILES); + } + } + @Test public void testWorkspaceFoldersRegistration() throws Exception { assertTrue(LanguageServiceAccessor.hasActiveLanguageServers(c -> true)); @@ -89,7 +117,7 @@ public void testWorkspaceFoldersRegistration() throws Exception { try { assertTrue(LanguageServiceAccessor.hasActiveLanguageServers(c -> hasWorkspaceFolderSupport(c))); } finally { - unregister(registration); + unregister(registration, WORKSPACE_DID_CHANGE_FOLDERS); } assertFalse(LanguageServiceAccessor.hasActiveLanguageServers(c -> hasWorkspaceFolderSupport(c))); assertTrue(LanguageServiceAccessor.hasActiveLanguageServers(c -> !hasWorkspaceFolderSupport(c))); @@ -97,13 +125,29 @@ public void testWorkspaceFoldersRegistration() throws Exception { ////////////////////////////////////////////////////////////////////////////////// - private void unregister(UUID registration) throws Exception { + private void unregister(UUID registration, String method) throws Exception { LanguageClient client = getMockClient(); - final var unregistration = new Unregistration(registration.toString(), WORKSPACE_EXECUTE_COMMAND); + final var unregistration = new Unregistration(registration.toString(), method); client.unregisterCapability(new UnregistrationParams(List.of(unregistration))) .get(1, TimeUnit.SECONDS); } + private UUID registerWatchedFiles() throws Exception { + var id = UUID.randomUUID(); + LanguageClient client = getMockClient(); + final var registration = new Registration(); + registration.setId(id.toString()); + registration.setMethod(WORKSPACE_DID_CHANGE_WATCHED_FILES); + // Only watch *.txt files to verify that glob-based filtering works + final var options = new org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions(); + final var watcher = new org.eclipse.lsp4j.FileSystemWatcher( + org.eclipse.lsp4j.jsonrpc.messages.Either.forLeft("**/*.txt"), null); + options.setWatchers(List.of(watcher)); + registration.setRegisterOptions(new Gson().toJsonTree(options)); + client.registerCapability(new RegistrationParams(List.of(registration))).get(1, TimeUnit.SECONDS); + return id; + } + private UUID registerWorkspaceFolders() throws Exception { UUID id = UUID.randomUUID(); LanguageClient client = getMockClient(); diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/files/FileSystemWatcherManagerTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/files/FileSystemWatcherManagerTest.java new file mode 100644 index 000000000..94c3e086d --- /dev/null +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/files/FileSystemWatcherManagerTest.java @@ -0,0 +1,199 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. 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: + * - Angelo ZERR (Red Hat Inc.) - initial API and implementation + * - Sebastian Thomschke (Vegard IT GmbH) - adapted the code from LSP4IJ to LSP4E + *******************************************************************************/ +package org.eclipse.lsp4e.test.files; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import org.eclipse.lsp4e.internal.files.FileSystemWatcherManager; +import org.eclipse.lsp4j.FileSystemWatcher; +import org.eclipse.lsp4j.WatchKind; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +// Based on https://github.com/redhat-developer/lsp4ij/blob/6f41f6d22a7146f31e0218cb459513abd5dc16d3/src/test/java/com/redhat/devtools/lsp4ij/features/files/FileSystemWatcherManagerTest.java + +/** + * Basic glob pattern tests for {@link FileSystemWatcherManager}. + *

+ * These are adapted from the LSP4IJ test suite to validate that the + * Eclipse-side implementation behaves consistently for typical patterns and + * watch kinds. + */ +class FileSystemWatcherManagerTest { + + private static final String DEFAULT_WATCHER_ID = "default"; + + private final Path projectDir = Paths.get("current-project").toAbsolutePath(); + private final Path externalDir = Paths.get("external-project").toAbsolutePath(); + + private final FileSystemWatcherManager manager = new FileSystemWatcherManager(projectDir); + + @BeforeEach + void setUp() { + manager.clear(); + } + + @AfterEach + void tearDown() { + manager.clear(); + } + + @Test + void sapCdsPatterns() { + // Patterns adapted from LSP4IJ sap_cds_ls test + registerWatchers(DEFAULT_WATCHER_ID, List.of( // + new FileSystemWatcher(Either.forLeft("package.json"), + Integer.valueOf(WatchKind.Create | WatchKind.Change | WatchKind.Delete)), new FileSystemWatcher(Either.forLeft("{.git,.cds}ignore"), + Integer.valueOf(WatchKind.Create | WatchKind.Change | WatchKind.Delete)), new FileSystemWatcher(Either.forLeft(".cdsrc.json"), + Integer.valueOf(WatchKind.Create | WatchKind.Change | WatchKind.Delete)), new FileSystemWatcher(Either.forLeft("**/{_i18n,i18n}/i18n{*.properties,*.json,*.csv}"), + Integer.valueOf(WatchKind.Create | WatchKind.Change | WatchKind.Delete)))); + + // Match package.json at project root + assertMatchFile(projectDir.resolve("package.json").toUri(), WatchKind.Create); + assertMatchFile(projectDir.resolve("package.json").toUri(), WatchKind.Change); + assertMatchFile(projectDir.resolve("package.json").toUri(), WatchKind.Delete); + + // Non-matching names/locations + assertNoMatchFile(projectDir.resolve("package.jso").toUri(), WatchKind.Create); + assertNoMatchFile(projectDir.resolve("foo").resolve("package.json").toUri(), WatchKind.Create); + assertNoMatchFile(externalDir.resolve("package.json").toUri(), WatchKind.Create); + + // Match {.git,.cds}ignore at project root + assertMatchFile(projectDir.resolve(".gitignore").toUri(), WatchKind.Create); + assertNoMatchFile(projectDir.resolve("gitignore").toUri(), WatchKind.Create); + + // Match .cdsrc.json at project root + assertMatchFile(projectDir.resolve(".cdsrc.json").toUri(), WatchKind.Create); + assertNoMatchFile(projectDir.resolve("cdsrc.json").toUri(), WatchKind.Create); + + // Match **/{_i18n,i18n}/i18n{*.properties,*.json,*.csv} + assertMatchFile(projectDir.resolve("_i18n").resolve("i18n.properties").toUri(), WatchKind.Create); + assertMatchFile(projectDir.resolve("i18n").resolve("i18n.json").toUri(), WatchKind.Create); + assertNoMatchFile(projectDir.resolve("other").resolve("i18n.properties").toUri(), WatchKind.Create); + } + + @Test + void watcherKindFiltering() { + // Register patterns with different explicit kinds + registerWatchers("watcher-kind", List.of( + new FileSystemWatcher(Either.forLeft("**/*.kind_null"), null), + new FileSystemWatcher(Either.forLeft("**/*.kind_7"), Integer.valueOf(7)), + new FileSystemWatcher(Either.forLeft("**/*.kind_Create"), Integer.valueOf(WatchKind.Create)), + new FileSystemWatcher(Either.forLeft("**/*.kind_Change"), Integer.valueOf(WatchKind.Change)), + new FileSystemWatcher(Either.forLeft("**/*.kind_Delete"), Integer.valueOf(WatchKind.Delete)))); + + URI createUri = projectDir.resolve("foo.kind_Create").toUri(); + URI changeUri = projectDir.resolve("foo.kind_Change").toUri(); + URI deleteUri = projectDir.resolve("foo.kind_Delete").toUri(); + URI nullUri = projectDir.resolve("foo.kind_null").toUri(); + URI anyUri = projectDir.resolve("foo.kind_7").toUri(); + + // kind null -> all kinds + assertMatchFile(nullUri, WatchKind.Create); + assertMatchFile(nullUri, WatchKind.Change); + assertMatchFile(nullUri, WatchKind.Delete); + + // kind 7 -> all kinds + assertMatchFile(anyUri, WatchKind.Create); + assertMatchFile(anyUri, WatchKind.Change); + assertMatchFile(anyUri, WatchKind.Delete); + + // specific kinds + assertMatchFile(createUri, WatchKind.Create); + assertNoMatchFile(createUri, WatchKind.Change); + assertNoMatchFile(createUri, WatchKind.Delete); + + assertNoMatchFile(changeUri, WatchKind.Create); + assertMatchFile(changeUri, WatchKind.Change); + assertNoMatchFile(changeUri, WatchKind.Delete); + + assertNoMatchFile(deleteUri, WatchKind.Create); + assertNoMatchFile(deleteUri, WatchKind.Change); + assertMatchFile(deleteUri, WatchKind.Delete); + } + + @Test + void globMatchingSimple() { + // Simple glob patterns, adapted from VS Code tests + registerGlobWatcher("node_modules"); + assertGlobMatch("node_modules"); + assertNoGlobMatch("node_module"); + assertNoGlobMatch("test/node_modules"); + + registerGlobWatcher("test.txt"); + assertGlobMatch("test.txt"); + + // Windows file systems do not allow '?' in file names. Keep the VS Code + // style assertion only on non-Windows platforms. + if (!isWindows()) { + assertNoGlobMatch("test?txt"); + } + assertNoGlobMatch("/text.txt"); + assertNoGlobMatch("test/test.txt"); + } + + private void registerWatchers(String id, List watchers) { + manager.registerFileSystemWatchers(id, watchers); + } + + private void assertMatchFile(URI uri, int kind) { + boolean matched = manager.isMatchFilePattern(uri, kind); + assertTrue(matched, () -> uri + " should match for kind " + kind); + } + + private void assertNoMatchFile(URI uri, int kind) { + boolean matched = manager.isMatchFilePattern(uri, kind); + assertFalse(matched, () -> uri + " should not match for kind " + kind); + } + + private void registerGlobWatcher(String pattern) { + manager.clear(); + manager.registerFileSystemWatchers(DEFAULT_WATCHER_ID, + List.of(new FileSystemWatcher(Either.forLeft(pattern), Integer.valueOf(WatchKind.Create)))); + } + + private void assertGlobMatch(String relativePath) { + assertGlobMatch(relativePath, true); + } + + private void assertNoGlobMatch(String relativePath) { + assertGlobMatch(relativePath, false); + } + + private void assertGlobMatch(String relativePath, boolean expected) { + URI uri; + if (relativePath.startsWith("/")) { + uri = projectDir.resolve(relativePath.substring(1)).toUri(); + } else { + uri = projectDir.resolve(relativePath).toUri(); + } + boolean matched = manager.isMatchFilePattern(uri, WatchKind.Create); + if (expected) { + assertTrue(matched, () -> "Pattern should match " + uri); + } else { + assertFalse(matched, () -> "Pattern should not match " + uri); + } + } + + private static boolean isWindows() { + String os = System.getProperty("os.name"); + return os != null && os.toLowerCase().contains("win"); + } +} diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/files/PathPatternMatcherTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/files/PathPatternMatcherTest.java new file mode 100644 index 000000000..e3043906e --- /dev/null +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/files/PathPatternMatcherTest.java @@ -0,0 +1,123 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat, Inc. 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: + * - Angelo ZERR (Red Hat Inc.) - initial API and implementation + * - Sebastian Thomschke (Vegard IT GmbH) - adapted the code from LSP4IJ to LSP4E + *******************************************************************************/ +package org.eclipse.lsp4e.test.files; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.eclipse.lsp4e.internal.files.PathPatternMatcher; +import org.junit.jupiter.api.Test; + +// Based on https://github.com/redhat-developer/lsp4ij/blob/6f41f6d22a7146f31e0218cb459513abd5dc16d3/src/test/java/com/redhat/devtools/lsp4ij/features/files/PathPatternMatcherTest.java + +/** + * Tests for glob pattern expansion in {@link PathPatternMatcher}. + */ +class PathPatternMatcherTest { + + @Test + void noExpansion() { + assertExpandPatterns("foo", "foo"); + } + + @Test + void oneExpansion() { + assertExpandPatterns("**/foo", "foo", "**/foo"); + } + + @Test + void twoExpansion() { + assertExpandPatterns("**/foo/**", "foo", "**/foo", "foo/**", "**/foo/**"); + } + + @Test + void sixExpansion() { + assertExpandPatterns("{**/node_modules/**,**/.git/**,**/bower_components/**}", // + "{node_modules,.git,bower_components}", // + "{node_modules,.git,bower_components/**}", // + "{node_modules,.git,**/bower_components}", // + "{node_modules,.git,**/bower_components/**}", // + "{node_modules,.git/**,bower_components}", // + "{node_modules,.git/**,bower_components/**}", // + "{node_modules,.git/**,**/bower_components}", // + "{node_modules,.git/**,**/bower_components/**}", // + "{node_modules,**/.git,bower_components}", // + "{node_modules,**/.git,bower_components/**}", // + "{node_modules,**/.git,**/bower_components}", // + "{node_modules,**/.git,**/bower_components/**}", // + "{node_modules,**/.git/**,bower_components}", // + "{node_modules,**/.git/**,bower_components/**}", // + "{node_modules,**/.git/**,**/bower_components}", // + "{node_modules,**/.git/**,**/bower_components/**}", // + "{node_modules/**,.git,bower_components}", // + "{node_modules/**,.git,bower_components/**}", // + "{node_modules/**,.git,**/bower_components}", // + "{node_modules/**,.git,**/bower_components/**}", // + "{node_modules/**,.git/**,bower_components}", // + "{node_modules/**,.git/**,bower_components/**}", // + "{node_modules/**,.git/**,**/bower_components}", // + "{node_modules/**,.git/**,**/bower_components/**}", // + "{node_modules/**,**/.git,bower_components}", // + "{node_modules/**,**/.git,bower_components/**}", // + "{node_modules/**,**/.git,**/bower_components}", // + "{node_modules/**,**/.git,**/bower_components/**}", // + "{node_modules/**,**/.git/**,bower_components}", // + "{node_modules/**,**/.git/**,bower_components/**}", // + "{node_modules/**,**/.git/**,**/bower_components}", // + "{node_modules/**,**/.git/**,**/bower_components/**}", // + "{**/node_modules,.git,bower_components}", // + "{**/node_modules,.git,bower_components/**}", // + "{**/node_modules,.git,**/bower_components}", // + "{**/node_modules,.git,**/bower_components/**}", // + "{**/node_modules,.git/**,bower_components}", // + "{**/node_modules,.git/**,bower_components/**}", // + "{**/node_modules,.git/**,**/bower_components}", // + "{**/node_modules,.git/**,**/bower_components/**}", // + "{**/node_modules,**/.git,bower_components}", // + "{**/node_modules,**/.git,bower_components/**}", // + "{**/node_modules,**/.git,**/bower_components}", // + "{**/node_modules,**/.git,**/bower_components/**}", // + "{**/node_modules,**/.git/**,bower_components}", // + "{**/node_modules,**/.git/**,bower_components/**}", // + "{**/node_modules,**/.git/**,**/bower_components}", // + "{**/node_modules,**/.git/**,**/bower_components/**}", // + "{**/node_modules/**,.git,bower_components}", // + "{**/node_modules/**,.git,bower_components/**}", // + "{**/node_modules/**,.git,**/bower_components}", // + "{**/node_modules/**,.git,**/bower_components/**}", // + "{**/node_modules/**,.git/**,bower_components}", // + "{**/node_modules/**,.git/**,bower_components/**}", // + "{**/node_modules/**,.git/**,**/bower_components}", // + "{**/node_modules/**,.git/**,**/bower_components/**}", // + "{**/node_modules/**,**/.git,bower_components}", // + "{**/node_modules/**,**/.git,bower_components/**}", // + "{**/node_modules/**,**/.git,**/bower_components}", // + "{**/node_modules/**,**/.git,**/bower_components/**}", // + "{**/node_modules/**,**/.git/**,bower_components}", // + "{**/node_modules/**,**/.git/**,bower_components/**}", // + "{**/node_modules/**,**/.git/**,**/bower_components}", // + "{**/node_modules/**,**/.git/**,**/bower_components/**}"); + } + + private static void assertExpandPatterns(String pattern, String... expectedPatterns) { + List actual = PathPatternMatcher.expandPatterns(pattern); + Collections.sort(actual); + List expected = Arrays.asList(expectedPatterns); + Collections.sort(expected); + assertArrayEquals(actual.toArray(String[]::new), expected.toArray(String[]::new), + "'" + pattern + "' pattern expansion should match [\"" + String.join("\",\"", actual) + "\"]"); + } +} diff --git a/org.eclipse.lsp4e.tests.mock/META-INF/MANIFEST.MF b/org.eclipse.lsp4e.tests.mock/META-INF/MANIFEST.MF index be5c7f7e3..283c40591 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.0.qualifier +Bundle-Version: 0.17.1.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 2eea81d30..798a26230 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 @@ -32,6 +32,7 @@ public class MockWorkspaceService implements WorkspaceService { private Function _futureFactory; private CompletableFuture executedCommand = new CompletableFuture<>(); private List workspaceFoldersEvents = new ArrayList<>(); + private List watchedFilesEvents = new ArrayList<>(); public MockWorkspaceService(Function> futureFactory) { this._futureFactory = futureFactory; @@ -63,6 +64,7 @@ public void didChangeConfiguration(DidChangeConfigurationParams params) { @Override public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { + watchedFilesEvents.add(params); } @Override @@ -74,6 +76,10 @@ public List getWorkspaceFoldersEvents() { return this.workspaceFoldersEvents; } + public List getWatchedFilesEvents() { + return this.watchedFilesEvents; + } + @Override public CompletableFuture executeCommand(ExecuteCommandParams params) { executedCommand.complete(params); diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/LanguageServerWrapper.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/LanguageServerWrapper.java index facc05664..de24efc8b 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/LanguageServerWrapper.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/LanguageServerWrapper.java @@ -14,10 +14,11 @@ * Kris De Volder (Pivotal, Inc.) - dynamic command registration * Tamas Miklossy (itemis) - bug 571162 * Rubén Porras Campo (Avaloq Evolution AG) - documentAboutToBeSaved implementation + * Sebastian Thomschke (Vegard IT GmbH) - textDocument/completion, workspace/didChangeWatchedFiles support; bug fixes *******************************************************************************/ package org.eclipse.lsp4e; -import static org.eclipse.lsp4e.internal.NullSafetyHelper.castNonNull; +import static org.eclipse.lsp4e.internal.NullSafetyHelper.*; import java.io.BufferedReader; import java.io.File; @@ -80,17 +81,22 @@ import org.eclipse.lsp4e.internal.CancellationUtil; import org.eclipse.lsp4e.internal.FileBufferListenerAdapter; import org.eclipse.lsp4e.internal.SupportedFeatures; +import org.eclipse.lsp4e.internal.files.FileSystemWatcherManager; import org.eclipse.lsp4e.server.StreamConnectionProvider; import org.eclipse.lsp4e.ui.Messages; import org.eclipse.lsp4j.ClientCapabilities; import org.eclipse.lsp4j.ClientInfo; import org.eclipse.lsp4j.CodeActionOptions; import org.eclipse.lsp4j.CompletionOptions; +import org.eclipse.lsp4j.DidChangeWatchedFilesParams; +import org.eclipse.lsp4j.DidChangeWatchedFilesRegistrationOptions; import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams; import org.eclipse.lsp4j.DocumentFormattingOptions; import org.eclipse.lsp4j.DocumentOnTypeFormattingOptions; import org.eclipse.lsp4j.DocumentRangeFormattingOptions; import org.eclipse.lsp4j.ExecuteCommandOptions; +import org.eclipse.lsp4j.FileChangeType; +import org.eclipse.lsp4j.FileEvent; import org.eclipse.lsp4j.InitializeParams; import org.eclipse.lsp4j.InitializeResult; import org.eclipse.lsp4j.InitializedParams; @@ -103,6 +109,7 @@ import org.eclipse.lsp4j.TextDocumentSyncOptions; import org.eclipse.lsp4j.TypeHierarchyRegistrationOptions; import org.eclipse.lsp4j.UnregistrationParams; +import org.eclipse.lsp4j.WatchKind; import org.eclipse.lsp4j.WindowClientCapabilities; import org.eclipse.lsp4j.WorkspaceFolder; import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent; @@ -276,6 +283,9 @@ synchronized void close() { private boolean initiallySupportsWorkspaceFolders = false; private final IResourceChangeListener workspaceFolderUpdater = new WorkspaceFolderListener(); + private final FileSystemWatcherManager fileSystemWatcherManager; + private final WatchedFilesListener watchedFilesListener = new WatchedFilesListener(); + /* Backwards compatible constructor */ public LanguageServerWrapper(IProject project, LanguageServerDefinition serverDefinition) { this(project, serverDefinition, null); @@ -314,6 +324,8 @@ private LanguageServerWrapper(@Nullable IProject project, LanguageServerDefiniti final var errorsThreadNameFormat = formatPrefix + "#errorProcessor"; //$NON-NLS-1$ this.errorProcessor = Executors .newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat(errorsThreadNameFormat).build()); + + this.fileSystemWatcherManager = new FileSystemWatcherManager(initialProject); } void stopDispatcher() { @@ -733,6 +745,8 @@ private void shutdown(LanguageServerContext workingContext) { this.dynamicRegistrations.clear(); ResourcesPlugin.getWorkspace().removeResourceChangeListener(workspaceFolderUpdater); + ResourcesPlugin.getWorkspace().removeResourceChangeListener(watchedFilesListener); + fileSystemWatcherManager.clear(); CompletableFuture.runAsync(workingContext::close); @@ -1124,6 +1138,27 @@ public void registerCapability(RegistrationParams params) { "Dynamic capability registration failed! Server not yet initialized?"); //$NON-NLS-1$ params.getRegistrations().forEach(reg -> { switch (reg.getMethod()) { + case "workspace/didChangeWatchedFiles": { //$NON-NLS-1$ + try { + DidChangeWatchedFilesRegistrationOptions options = toDidChangeWatchedFilesRegistrationOptions( + reg.getRegisterOptions()); + if (options != null && !options.getWatchers().isEmpty()) { + fileSystemWatcherManager.registerFileSystemWatchers(reg.getId(), options.getWatchers()); + enableWatchedFiles(); + addRegistration(reg, () -> { + fileSystemWatcherManager.unregisterFileSystemWatchers(reg.getId()); + disableWatchedFiles(); + }); + } else { + // No usable watchers - still track registration so it can be unregistered cleanly + addRegistration(reg, this::disableWatchedFiles); + } + } catch (final Exception ex) { + LanguageServerPlugin.logError(ex); + addRegistration(reg, this::disableWatchedFiles); + } + break; + } case "workspace/didChangeWorkspaceFolders": //$NON-NLS-1$ if (initiallySupportsWorkspaceFolders) { // Can treat this as a NOP since nothing can disable it dynamically if it was @@ -1214,6 +1249,17 @@ public void registerCapability(RegistrationParams params) { }}); } + private static @Nullable DidChangeWatchedFilesRegistrationOptions toDidChangeWatchedFilesRegistrationOptions( + @Nullable Object registerOptions) { + if (registerOptions == null) + return null; + if (registerOptions instanceof DidChangeWatchedFilesRegistrationOptions direct) + return direct; + if (registerOptions instanceof JsonObject jsonObject) + return new Gson().fromJson(jsonObject, DidChangeWatchedFilesRegistrationOptions.class); + return null; + } + private void addRegistration(Registration reg, Runnable unregistrationHandler) { String regId = reg.getId(); synchronized (dynamicRegistrations) { @@ -1225,6 +1271,18 @@ private void addRegistration(Registration reg, Runnable unregistrationHandler) { } } + synchronized void disableWatchedFiles() { + if (!fileSystemWatcherManager.hasFilePatterns()) { + ResourcesPlugin.getWorkspace().removeResourceChangeListener(watchedFilesListener); + } + } + + synchronized void enableWatchedFiles() { + if (fileSystemWatcherManager.hasFilePatterns()) { + ResourcesPlugin.getWorkspace().addResourceChangeListener(watchedFilesListener, IResourceChangeEvent.POST_CHANGE); + } + } + synchronized void setWorkspaceFoldersEnablement(boolean enable) { if (enable == supportsWorkspaceFolderCapability()) { return; @@ -1442,6 +1500,119 @@ private boolean isValid(@Nullable WorkspaceFolder wsFolder) { } + /** + * Resource listener that translates Eclipse resource change events into LSP + * file watch events and dispatches them if the language server is still active + */ + private final class WatchedFilesListener implements IResourceChangeListener { + + private record WatchedFileChange(URI uri, FileChangeType changeType) { + } + + @Override + public void resourceChanged(final IResourceChangeEvent event) { + // Fast-path: if no watchers are registered, skip work entirely + if (!fileSystemWatcherManager.hasFilePatterns()) + return; + + final List changes = collectChanges(event); + if (changes.isEmpty()) + return; + + final LanguageServer currentServer = context.languageServer; + if (currentServer == null) + return; + + // Offload potentially expensive glob matching and notification dispatching + // to the language-server dispatcher thread to avoid blocking the workspace + // resource change thread. + dispatcher.execute(() -> { + final LanguageServer serverInContext = context.languageServer; + if (serverInContext == null || serverInContext != currentServer) + return; + + final var fileEvents = new ArrayList(); + for (final WatchedFileChange change : changes) { + final int watchKind = toWatchKind(change.changeType()); + if (!fileSystemWatcherManager.isMatchFilePattern(change.uri(), watchKind)) { + continue; + } + final var fileEvent = new FileEvent(); + fileEvent.setUri(change.uri().toASCIIString()); + fileEvent.setType(change.changeType()); + fileEvents.add(fileEvent); + } + if (fileEvents.isEmpty()) + return; + serverInContext.getWorkspaceService() + .didChangeWatchedFiles(new DidChangeWatchedFilesParams(fileEvents)); + }); + } + + private List collectChanges(final IResourceChangeEvent event) { + if (event.getType() != IResourceChangeEvent.POST_CHANGE || event.getDelta() == null) + return List.of(); + + final var relevantFolders = getRelevantWorkspaceFolders(); + final var changes = new ArrayList(); + try { + event.getDelta().accept(delta -> { + final IResource resource = delta.getResource(); + if (resource.getType() == IResource.ROOT) + return true; + if (resource.getType() != IResource.FILE) + return true; + if (!(resource instanceof IFile file)) + return false; + + final WorkspaceFolder wsFolder = LSPEclipseUtils.toWorkspaceFolder(file.getProject()); + if (!relevantFolders.contains(wsFolder)) + return false; + + final FileChangeType changeType = getFileChangeType(delta); + if (changeType == null) + return false; + + final URI uri = LSPEclipseUtils.toUri(file); + if (uri == null) + return false; + + changes.add(new WatchedFileChange(uri, changeType)); + + return false; + }); + } catch (final CoreException ex) { + LanguageServerPlugin.logError(ex); + } + return changes; + } + + private int toWatchKind(FileChangeType changeType) { + return switch (changeType) { + case Created -> WatchKind.Create; + case Changed -> WatchKind.Change; + case Deleted -> WatchKind.Delete; + default -> WatchKind.Create; + }; + } + + private @Nullable FileChangeType getFileChangeType(final IResourceDelta delta) { + return switch (delta.getKind()) { + case IResourceDelta.ADDED -> FileChangeType.Created; + case IResourceDelta.REMOVED -> FileChangeType.Deleted; + case IResourceDelta.CHANGED -> { + int flags = delta.getFlags(); + if ((flags & (IResourceDelta.CONTENT | IResourceDelta.REPLACED | IResourceDelta.MOVED_FROM + | IResourceDelta.MOVED_TO)) != 0) { + yield FileChangeType.Changed; + } + yield null; + } + default -> null; + }; + } + } + /** * Extracts the root cause message from a nested exception chain. * This helps provide cleaner error messages in dialogs by avoiding @@ -1464,4 +1635,4 @@ private static String getThrowableMessage(Throwable throwable) { return message != null ? message : "No exception message available: " + throwable.getClass().getSimpleName(); //$NON-NLS-1$ } -} \ No newline at end of file +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/files/FileSystemWatcherManager.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/files/FileSystemWatcherManager.java new file mode 100644 index 000000000..90317c11b --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/files/FileSystemWatcherManager.java @@ -0,0 +1,382 @@ +/******************************************************************************* + * Copyright (c) 2024 Red Hat Inc. 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: + * - Angelo ZERR (Red Hat Inc.) - initial API and implementation + * - Sebastian Thomschke (Vegard IT GmbH) - adapted the code from LSP4IJ to LSP4E; improved thread safty + *******************************************************************************/ +package org.eclipse.lsp4e.internal.files; + +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.core.resources.IProject; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.lsp4e.LanguageServerPlugin; +import org.eclipse.lsp4j.FileSystemWatcher; +import org.eclipse.lsp4j.RelativePattern; +import org.eclipse.lsp4j.WatchKind; +import org.eclipse.lsp4j.WorkspaceFolder; +import org.eclipse.lsp4j.jsonrpc.messages.Either; + +// Based on https://github.com/redhat-developer/lsp4ij/blob/6f41f6d22a7146f31e0218cb459513abd5dc16d3/src/main/java/com/redhat/devtools/lsp4ij/features/files/watcher/FileSystemWatcherManager.java + +/** + * LSP file system manager which matches a given URI by using LSP + * {@link FileSystemWatcher}. + */ +public final class FileSystemWatcherManager { + + private static final int WATCH_KIND_ANY = 7; + + private final Map> registry = new HashMap<>(); + private final @Nullable Path basePath; + + private volatile @Nullable Set fileSystemWatchers; + private volatile @Nullable Map> pathPatternMatchers; + + public FileSystemWatcherManager(final @Nullable IProject project) { + Path watchedFilesBasePath = null; + try { + if (project != null) { + final var loc = project.getLocationURI(); + if (loc != null) + watchedFilesBasePath = Paths.get(loc); + } + } catch (IllegalArgumentException ex) { + LanguageServerPlugin.logError(ex); + } + this.basePath = watchedFilesBasePath; + } + + public FileSystemWatcherManager(final @Nullable Path basePath) { + this.basePath = basePath; + } + + /** + * Register the file system watcher list with the given id. + */ + public void registerFileSystemWatchers(final String id, final @Nullable List watchers) { + if (watchers == null) + return; + + synchronized (registry) { + registry.put(id, new ArrayList<>(watchers)); + reset(); + } + } + + /** + * Unregister the file system watcher list with the given id. + */ + public void unregisterFileSystemWatchers(final String id) { + synchronized (registry) { + registry.remove(id); + reset(); + } + } + + /** + * Removes all registered watchers. + */ + public void clear() { + synchronized (registry) { + registry.clear(); + reset(); + } + } + + private void reset() { + fileSystemWatchers = registry.values().stream() // + .flatMap(List::stream) // + .collect(Collectors.toCollection(HashSet::new)); + pathPatternMatchers = null; + } + + /** + * Returns an unmodifiable snapshot of the currently registered LSP file system + * watchers, or {@code null} if none are registered. + * + * @return an unmodifiable set of LSP file system watchers, or {@code null} if + * there are no watchers + */ + public @Nullable Set getFileSystemWatchers() { + final Set watchers = this.fileSystemWatchers; + return watchers == null ? null : Set.copyOf(watchers); + } + + /** + * Returns true if there are some file system watchers and false otherwise. + * + * @return true if there are some file system watchers and false otherwise. + */ + public boolean hasFilePatterns() { + return fileSystemWatchers != null && !fileSystemWatchers.isEmpty(); + } + + public boolean hasFilePatternsFor(final int kind) { + if (!hasFilePatterns()) + return false; + + // Ensure pattern matchers are initialized before use + computePatternMatchersIfNeeded(); + + final var pathPatternMatchers = this.pathPatternMatchers; + if (pathPatternMatchers == null) + return false; + + final List matchersForKind = pathPatternMatchers.get(kind); + return matchersForKind != null && !matchersForKind.isEmpty(); + } + + /** + * Returns true if the given uri matches a pattern for the given watch kind and + * false otherwise. + * + * @param uri + * the uri to match. + * @param kind + * the watch kind ({@link WatchKind#Create}, + * {@link WatchKind#Change}, {@link WatchKind#Delete} or 7 (for any)) + * @return true if the given uri matches a pattern for the given watch kind and + * false otherwise. + */ + public boolean isMatchFilePattern(final @Nullable URI uri, final int kind) { + // If no URI or no patterns are registered, there can be no match + if (uri == null || !hasFilePatterns()) + return false; + + // Ensure pattern matchers are initialized before use + computePatternMatchersIfNeeded(); + + // Cache: basePath -> relative path if included, false otherwise + final Map> basePathToRelativePath = new HashMap<>(); + + try { + // Convert the URI to a Path for matching + final Path path = Paths.get(uri); + + // Match against the given kind or the "any" kind + return match(path, kind, basePathToRelativePath) || match(path, WATCH_KIND_ANY, basePathToRelativePath); + + } catch (final Exception ex) { + // Any failure in URI-to-Path conversion or matching is treated as "no match" + LanguageServerPlugin.logWarning(ex.getMessage(), ex); + } + return false; + } + + private void computePatternMatchersIfNeeded() { + if (pathPatternMatchers == null) { + computePatternMatchers(); + } + } + + private synchronized void computePatternMatchers() { + if (pathPatternMatchers != null) { + return; + } + final Set watchers = this.fileSystemWatchers; + if (watchers == null) { + pathPatternMatchers = Map.of(); + return; + } + + final var matchers = new HashMap>(); + for (final FileSystemWatcher watcher : watchers) { + final PathPatternMatcher matcher = getPathPatternMatcher(watcher, basePath); + if (matcher != null) { + final Integer kind = watcher.getKind(); + tryAddingMatcher(matcher, matchers, kind, WatchKind.Create); + tryAddingMatcher(matcher, matchers, kind, WatchKind.Change); + tryAddingMatcher(matcher, matchers, kind, WatchKind.Delete); + } + } + pathPatternMatchers = matchers; + } + + /** + * Checks whether the given {@link Path} matches any registered + * {@link PathPatternMatcher} for the specified watch kind. + * + *

+ * This method iterates through all pattern matchers for the given {@code kind}, + * checks if the provided path is under each matcher’s base path, and if so, + * applies the matcher to the relative path. + *

+ * + *

+ * To optimize performance, a cache map is used to store intermediate results + * for base path checks: + *

    + *
  • Key: the base path of a matcher
  • + *
  • Value: Either: + *
      + *
    • Left - the relative path if the path is under the base path
    • + *
    • Right(false) - indicates the path is not under this base path
    • + *
    + *
  • + *
+ *

+ * + * @param path + * the file path to test, must not be {@code null} + * @param kind + * the watch kind to check against (e.g., {@link WatchKind#Create}, + * {@link WatchKind#Change}, {@link WatchKind#Delete}, or + * {@code WATCH_KIND_ANY} for a wildcard match) + * @param basePathToRelativePath + * a cache for storing relative paths or negative results + * @return {@code true} if the path matches any pattern for the given kind, + * {@code false} otherwise + */ + private boolean match(final Path path, final int kind, + final Map> basePathToRelativePath) { + + final var pathPatternMatchers = this.pathPatternMatchers; + if (pathPatternMatchers == null) + return false; + + // Retrieve all matchers registered for the given kind + final List matchers = pathPatternMatchers.get(Integer.valueOf(kind)); + if (matchers == null) + return false; // No matchers for this kind + + // Iterate over each matcher + for (final var matcher : matchers) { + // Check if the path is under the matcher’s base path + final Path matcherBasePath = matcher.getBasePath(); + if (matcherBasePath == null) + continue; + final Path relativePath = matchBasePath(path, matcherBasePath, basePathToRelativePath); + if (relativePath == null) + continue; + // Apply the matcher to the relative path + if (matcher.matches(matcherBasePath.relativize(path))) + return true; + } + + // No matcher matched + return false; + } + + /** + * Checks whether the given {@link Path} is located under a specified base path. + * + *

+ * If the path is under the base path, the relative path is returned and cached. + * If the path is not under the base path, the result is cached as a negative + * match. + *

+ * + * @param path + * the path to check, must not be {@code null} + * @param basePath + * the base path to test against, may be {@code null} + * @param basePathToRelativePath + * cache map to store computed results + * @return the relative path between {@code basePath} and {@code path} if + * included, or {@code null} if the path is not under the base path + */ + private static @Nullable Path matchBasePath(final Path path, final @Nullable Path basePath, + final Map> basePathToRelativePath) { + if (basePath == null) + return null; // No base path to check + + // Check cache first + final Either matches = basePathToRelativePath.get(basePath); + if (matches != null) { + return matches.isLeft() // + ? matches.getLeft() // Cached positive result + : null; // Cached negative result + } + + // Compute for the first time + if (path.startsWith(basePath)) { + final Path relativePath = basePath.relativize(path); + basePathToRelativePath.put(basePath, Either.forLeft(relativePath)); // Cache positive result + return relativePath; + } + + // Path is not under base path, cache negative result + basePathToRelativePath.put(basePath, Either.forRight(Boolean.FALSE)); + return null; + } + + private static @Nullable PathPatternMatcher getPathPatternMatcher(final FileSystemWatcher fileSystemMatcher, + final @Nullable Path basePath) { + final Either globPattern = fileSystemMatcher.getGlobPattern(); + if (globPattern.isLeft()) { + final String pattern = globPattern.getLeft(); + return pattern.isBlank() // + ? null // Invalid pattern, ignore the watcher + : new PathPatternMatcher(pattern, basePath); + } + final RelativePattern relativePattern = globPattern.getRight(); + // Implement relative pattern like glob string pattern + // by waiting for finding a concrete use case. + final String pattern = relativePattern.getPattern(); + if (pattern.isBlank()) + return null; // Invalid pattern, ignore the watcher + + final Path relativeBasePath = getRelativeBasePath(relativePattern.getBaseUri()); + if (relativeBasePath == null) { + // Invalid baseUri, ignore the watcher + return null; + } + return new PathPatternMatcher(pattern, relativeBasePath); + } + + private static @Nullable Path getRelativeBasePath(final @Nullable Either baseUri) { + if (baseUri == null) + return null; + + String baseDir = null; + if (baseUri.isRight()) { + baseDir = baseUri.getRight(); + } else if (baseUri.isLeft()) { + final var workspaceFolder = baseUri.getLeft(); + baseDir = workspaceFolder.getUri(); + } + if (baseDir == null || baseDir.isBlank()) + return null; + + try { + return Paths.get(URI.create(baseDir)); + } catch (final Exception ex) { + // Invalid baseUri, ignore the watcher + LanguageServerPlugin.logWarning(ex.getMessage(), ex); + } + return null; + } + + private static void tryAddingMatcher(final PathPatternMatcher matcher, + final Map> matchers, final @Nullable Integer watcherKind, + final int kind) { + if (!isWatchKind(watcherKind, kind)) + return; + + final List matchersForKind = matchers.computeIfAbsent(kind, k -> new ArrayList<>()); + matchersForKind.add(matcher); + } + + /** + * Checks if the combined value contains a specific kind. + */ + private static boolean isWatchKind(final @Nullable Integer watcherKind, final int kind) { + return watcherKind == null || (watcherKind & kind) != 0; + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/files/PathPatternMatcher.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/files/PathPatternMatcher.java new file mode 100644 index 000000000..41c07283a --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/files/PathPatternMatcher.java @@ -0,0 +1,202 @@ +/******************************************************************************* + * Copyright (c) 2019 Red Hat Inc. 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: + * - Angelo ZERR (Red Hat Inc.) - initial API and implementation + * - Sebastian Thomschke (Vegard IT GmbH) - adapted the code from LSP4IJ to LSP4E + *******************************************************************************/ +package org.eclipse.lsp4e.internal.files; + +import java.net.URI; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import org.eclipse.jdt.annotation.Nullable; + +// Based on https://github.com/redhat-developer/lsp4ij/blob/6f41f6d22a7146f31e0218cb459513abd5dc16d3/src/main/java/com/redhat/devtools/lsp4ij/features/files/PathPatternMatcher.java +public final class PathPatternMatcher { + + private record Parts(List parts, List cols) { + } + + private @Nullable List pathMatchers; + private final String pattern; + private final @Nullable Path basePath; + + public PathPatternMatcher(final String pattern, final @Nullable Path basePath) { + this.pattern = pattern; + this.basePath = basePath; + } + + public String getPattern() { + return pattern; + } + + public @Nullable Path getBasePath() { + return basePath; + } + + public boolean matches(final URI uri) { + return internalMatches(Paths.get(uri)); + } + + public boolean matches(final Path path) { + return internalMatches(path); + } + + private boolean internalMatches(final Path pathToMatch) { + if (pattern.isEmpty()) + return false; + + var pathMatchers = this.pathMatchers; + if (pathMatchers == null) { + pathMatchers = this.pathMatchers = createPathMatchers(); + } + try { + for (final PathMatcher pathMatcher : pathMatchers) { + try { + if (pathMatcher.matches(pathToMatch)) { + return true; + } + } catch (final Exception ex) { + // ignore matcher errors, treat as non-match + } + } + } catch (final Exception e) { + // ignore URI/Path conversion errors, treat as non-match + } + return false; + } + + private synchronized List createPathMatchers() { + final String glob = pattern.replace("\\", "/"); //$NON-NLS-1$ //$NON-NLS-2$ + // As Java NIO glob does not support **/ or /** as optional + // we need to expand the pattern, ex: **/foo -> foo, **/foo. + final List expandedPatterns = expandPatterns(glob); + final var compiledMatchers = new ArrayList(); + for (final var expandedPattern : expandedPatterns) { + try { + final PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher("glob:" + expandedPattern); //$NON-NLS-1$ + compiledMatchers.add(pathMatcher); + } catch (final Exception ex) { + // ignore invalid glob expressions + } + } + return compiledMatchers; + } + + @Override + public boolean equals(final @Nullable Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + final var other = (PathPatternMatcher) obj; + return Objects.equals(basePath, other.basePath) // + && Objects.equals(pathMatchers, other.pathMatchers) // + && Objects.equals(pattern, other.pattern); + } + + @Override + public int hashCode() { + return Objects.hash(basePath, pathMatchers, pattern); + } + + /** + * Expand the given pattern. ex: **/foo -> foo, **/foo. + */ + public static List expandPatterns(final String pattern) { + final Parts parts = getParts(pattern); + if (parts != null) { + // tokenize pattern ex : **/foo/** --> [**/, foo, /**] + final var expanded = new ArrayList(); + // generate combinations array with 0,1 according to the number of **/, /** + // ex: **/foo/** (number=2) --> [[0, 0], [0, 1], [1, 0], [1, 1]] + final List combinations = generateCombinations(parts.cols().size()); + for (final int[] combination : combinations) { + // Clone tokenized pattern (ex : [**/, foo, /**]) + final var expand = new ArrayList(parts.parts()); + for (int i = 0; i < combination.length; i++) { + // Loop for current combination (ex : [0, 1]) + if (combination[i] == 0) { + // When 0, replace **/, /** with "" + // ex : [**/, foo, /**] --> ["", foo, "/**"] + final int col = parts.cols().get(i); + expand.set(col, ""); //$NON-NLS-1$ + } + } + // ["", foo, "/**"] --> foo/** + expanded.add(String.join("", expand)); //$NON-NLS-1$ + } + return expanded; + } + return Collections.singletonList(pattern); + } + + private static @Nullable Parts getParts(final String pattern) { + int from = 0; + int index = getNextIndex(pattern, from); + if (index != -1) { + final List cols = new ArrayList<>(); + final List parts = new ArrayList<>(); + while (index != -1) { + final String s = pattern.substring(from, index); + if (!s.isEmpty()) { + parts.add(s); + } + cols.add(Integer.valueOf(parts.size())); + from = index + 3; + parts.add(pattern.substring(index, from)); + index += 3; + index = getNextIndex(pattern, index); + } + parts.add(pattern.substring(from)); + return new Parts(parts, cols); + } + return null; + } + + private static int getNextIndex(final String pattern, final int fromIndex) { + final int startSlashIndex = pattern.indexOf("**/", fromIndex); //$NON-NLS-1$ + final int endSlashIndex = pattern.indexOf("/**", fromIndex); //$NON-NLS-1$ + if (startSlashIndex != -1 || endSlashIndex != -1) { + if (startSlashIndex == -1) + return endSlashIndex; + if (endSlashIndex == -1) + return startSlashIndex; + return Math.min(startSlashIndex, endSlashIndex); + } + return -1; + } + + private static List generateCombinations(final int count) { + final var combinations = new ArrayList(); + generateCombinationsHelper(count, new int[count], 0, combinations); + return combinations; + } + + private static void generateCombinationsHelper(final int count, final int[] combination, final int index, + final List combinations) { + if (index == count) { + combinations.add(combination.clone()); + } else { + combination[index] = 0; + generateCombinationsHelper(count, combination, index + 1, combinations); + combination[index] = 1; + generateCombinationsHelper(count, combination, index + 1, combinations); + } + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/files/package-info.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/files/package-info.java new file mode 100644 index 000000000..06e796963 --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/files/package-info.java @@ -0,0 +1,6 @@ +@NonNullByDefault({ ARRAY_CONTENTS, PARAMETER, RETURN_TYPE, FIELD, TYPE_BOUND, TYPE_ARGUMENT }) +package org.eclipse.lsp4e.internal.files; + +import static org.eclipse.jdt.annotation.DefaultLocation.*; + +import org.eclipse.jdt.annotation.NonNullByDefault;