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
+ *
+ * @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;