diff --git a/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/TestMarkdown.java b/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/TestMarkdown.java index 90ad518d80..4fadc00322 100644 --- a/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/TestMarkdown.java +++ b/org.eclipse.wildwebdeveloper.tests/src/org/eclipse/wildwebdeveloper/tests/TestMarkdown.java @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -27,13 +28,17 @@ import org.eclipse.core.resources.IResource; import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.CoreException; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.contentassist.ICompletionProposal; import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4e.LanguageServiceAccessor; +import org.eclipse.lsp4e.operations.completion.LSContentAssistProcessor; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.editors.text.TextEditor; import org.eclipse.ui.ide.IDE; import org.eclipse.ui.tests.harness.util.DisplayHelper; +import org.eclipse.wildwebdeveloper.Activator; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -123,4 +128,61 @@ void diagnosticsCoverTypicalMarkdownIssues() throws Exception { assertTrue(markerTests.isEmpty(), "The following markers were not found: " + markerTests); } + + @Test + void workspaceHeaderCompletionsRespectExcludeGlobs() throws Exception { + var project = ResourcesPlugin.getWorkspace().getRoot().getProject(getClass().getName() + ".hdr" + System.nanoTime()); + project.create(null); + project.open(null); + + // Configure exclusion: exclude docs/generated/** from workspace header completions + Activator.getDefault().getPreferenceStore().setValue("markdown.suggest.paths.excludeGlobs", "docs/generated/**"); + + // Create markdown files with unique headers + // Ensure folders exist + var docsFolder = project.getFolder("docs"); + if (!docsFolder.exists()) + docsFolder.create(true, true, null); + var genFolder = docsFolder.getFolder("generated"); + if (!genFolder.exists()) + genFolder.create(true, true, null); + + IFile excluded = project.getFile("docs/generated/excluded.md"); + excluded.create("# Excluded Only\n".getBytes(StandardCharsets.UTF_8), true, false, null); + + IFile included = project.getFile("docs/included.md"); + included.create("# Included Only\n".getBytes(StandardCharsets.UTF_8), true, false, null); + + // File where we'll trigger completions (double hash to respect default preference) + IFile index = project.getFile("index.md"); + index.create("[](##)\n".getBytes(StandardCharsets.UTF_8), true, false, null); + + var editor = (TextEditor) IDE.openEditor(PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(), index); + var display = editor.getSite().getShell().getDisplay(); + IDocument document = editor.getDocumentProvider().getDocument(editor.getEditorInput()); + + // Ensure Markdown Language Server is started and connected + var markdownLS = new AtomicReference(); + assertTrue(DisplayHelper.waitForCondition(display, 10_000, () -> { + markdownLS.set(LanguageServiceAccessor.getStartedWrappers(document, null, false).stream() // + .filter(w -> "org.eclipse.wildwebdeveloper.markdown".equals(w.serverDefinition.id)) // + .findFirst().orElse(null)); + return markdownLS.get() != null // + && markdownLS.get().isActive() // + && markdownLS.get().isConnectedTo(LSPEclipseUtils.toUri(document)); + }), "Markdown LS did not start"); + + // Trigger content assist at the end of '##' + int offset = document.get().indexOf("##") + 2; + var cap = new LSContentAssistProcessor(); + + assertTrue(DisplayHelper.waitForCondition(display, 15_000, () -> { + ICompletionProposal[] proposals = cap.computeCompletionProposals(Utils.getViewer(editor), offset); + if (proposals == null || proposals.length == 0) + return false; + boolean hasIncluded = Arrays.stream(proposals).anyMatch(p -> "#included-only".equals(p.getDisplayString())); + boolean hasExcluded = Arrays.stream(proposals).anyMatch(p -> "#excluded-only".equals(p.getDisplayString())); + return hasIncluded && !hasExcluded; + }), "Workspace header completions did not respect exclude globs"); + } } diff --git a/org.eclipse.wildwebdeveloper/META-INF/MANIFEST.MF b/org.eclipse.wildwebdeveloper/META-INF/MANIFEST.MF index a4ee44274e..b02e2422ff 100644 --- a/org.eclipse.wildwebdeveloper/META-INF/MANIFEST.MF +++ b/org.eclipse.wildwebdeveloper/META-INF/MANIFEST.MF @@ -35,7 +35,8 @@ Require-Bundle: org.eclipse.ui, Bundle-RequiredExecutionEnvironment: JavaSE-21 Bundle-ActivationPolicy: lazy Eclipse-BundleShape: dir -Export-Package: org.eclipse.wildwebdeveloper.debug;x-internal:=true, +Export-Package: org.eclipse.wildwebdeveloper;x-friends:="org.eclipse.wildwebdeveloper.tests", + org.eclipse.wildwebdeveloper.debug;x-internal:=true, org.eclipse.wildwebdeveloper.debug.node;x-internal:=true, org.eclipse.wildwebdeveloper.debug.npm;x-internal:=true, org.eclipse.wildwebdeveloper.markdown;x-friends:="org.eclipse.wildwebdeveloper.tests" diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java index 0a5e17d07d..e592fb75ab 100644 --- a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java @@ -18,11 +18,12 @@ import java.net.URISyntaxException; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; import java.nio.file.Files; 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.HashMap; import java.util.List; import java.util.Map; @@ -38,6 +39,7 @@ import org.eclipse.core.resources.ResourcesPlugin; import org.eclipse.core.runtime.FileLocator; import org.eclipse.core.runtime.ILog; +import org.eclipse.core.runtime.IPath; import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4e.client.DefaultLanguageClient; import org.eclipse.lsp4j.ConfigurationItem; @@ -140,7 +142,7 @@ public void resourceChanged(final IResourceChangeEvent event) { // Notify server of the file system event for this resource final var payload = new HashMap(); payload.put("id", Integer.valueOf(id)); - payload.put("uri", uri.toString()); + payload.put("uri", normalizeFileUriForLanguageServer(uri)); payload.put("kind", kind); server.fsWatcherOnChange(payload); return true; @@ -212,7 +214,7 @@ public CompletableFuture>> parseMarkdown(final Map>> parseMarkdown(final Map> tokens = gson.fromJson(out, List.class); @@ -242,7 +244,7 @@ public CompletableFuture>> parseMarkdown(final Map>> parseMarkdown(final Map>> parseMarkdown(final Map> findMarkdownFilesInWorkspace(final Object unused) { return CompletableFuture.supplyAsync(() -> { final var uris = new ArrayList(); + // Compile exclude globs from preferences once per request + final String[] excludeGlobs = MarkdownPreferences.getSuggestPathsExcludeGlobs(); + final List excludeMatchers = compileGlobMatchers(excludeGlobs); try { final var roots = MarkdownLanguageServer.getServerRoots(); if (roots != null && !roots.isEmpty()) { for (String rootUri : roots) { final var containers = ResourcesPlugin.getWorkspace().getRoot() - .findContainersForLocationURI(new java.net.URI(rootUri)); + .findContainersForLocationURI(URI.create(rootUri)); if (containers != null && containers.length > 0) { for (final var container : containers) { + if (container.isDerived() || container.isHidden()) + continue; container.accept((final IResource res) -> { + if (res.isDerived() || res.isHidden()) + return false; if (res.getType() == IResource.FILE) { final String name = res.getName().toLowerCase(); - if (name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) { - uris.add(res.getLocationURI().toString()); + if ((name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) + && !isExcludedByGlobs(res, excludeMatchers)) { + uris.add(normalizeFileUriForLanguageServer(res.getLocationURI())); } return false; // no children } @@ -295,10 +305,13 @@ public CompletableFuture> findMarkdownFilesInWorkspace(final Object // Fallback: scan entire workspace final IWorkspaceRoot wsRoot = ResourcesPlugin.getWorkspace().getRoot(); wsRoot.accept((final IResource res) -> { + if (res.isDerived() || res.isHidden()) + return false; if (res.getType() == IResource.FILE) { final String name = res.getName().toLowerCase(); - if (name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) { - uris.add(res.getLocationURI().toString()); + if ((name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) + && !isExcludedByGlobs(res, excludeMatchers)) { + uris.add(normalizeFileUriForLanguageServer(res.getLocationURI())); } return false; // no children } @@ -312,6 +325,48 @@ public CompletableFuture> findMarkdownFilesInWorkspace(final Object }); } + private static List compileGlobMatchers(String... globs) { + if (globs == null || globs.length == 0) + return List.of(); + + var fs = FileSystems.getDefault(); + var matchers = new ArrayList(); + + for (String glob : globs) { + if (glob == null || (glob = glob.trim()).isEmpty()) + continue; + + // If pattern starts with "**/", also add a root-level variant without it. + // This makes "**/node_modules/**" also match "node_modules/**". + List patterns = glob.startsWith("**/") && glob.length() > 3 + ? List.of(glob, glob.substring(3)) + : List.of(glob); + + for (String pattern : patterns) { + try { + matchers.add(fs.getPathMatcher("glob:" + pattern)); + } catch (Exception ex) { + ILog.get().warn(ex.getMessage(), ex); + } + } + } + return matchers; + } + + private static boolean isExcludedByGlobs(final IResource res, final List matchers) { + if (matchers == null || matchers.isEmpty()) + return false; + final IPath pr = res.getProjectRelativePath(); + if (pr == null) + return false; + final Path p = pr.toPath(); + for (final PathMatcher m : matchers) { + if (m.matches(p)) + return true; + } + return false; + } + /** *
 	 * Request: { uri: string }
@@ -409,10 +464,10 @@ public CompletableFuture fsWatcherCreate(final Map params)
 			@SuppressWarnings("unchecked")
 			final Map options = params.get("options") instanceof Map //
 					? (Map) params.get("options")
-					: Collections.emptyMap();
-			final var watcher = new Watcher((MarkdownLanguageServerAPI) getLanguageServer(), id, path,
+					: Map.of();
+			final var watcher = new Watcher((MarkdownLanguageServerAPI) getLanguageServer(), id, path, //
 					Boolean.TRUE.equals(options.get("ignoreCreate")), //
-					Boolean.TRUE.equals(options.get("ignoreChange")),
+					Boolean.TRUE.equals(options.get("ignoreChange")), //
 					Boolean.TRUE.equals(options.get("ignoreDelete")));
 			ResourcesPlugin.getWorkspace().addResourceChangeListener(watcher, IResourceChangeEvent.POST_CHANGE);
 			watchersById.put(Integer.valueOf(id), watcher);
@@ -439,4 +494,35 @@ public CompletableFuture fsWatcherDelete(final Map params)
 			return null;
 		});
 	}
+
+	/**
+	 * Normalize Windows file URIs to match vscode-uri's URI.toString() form used by the server.
+	 * 
+ * Examples: + *
  • file:/D:/path -> file:///d%3A/path + *
  • file:///D:/path -> file:///d%3A/path + */ + private static String normalizeFileUriForLanguageServer(final URI uri) { + if (uri == null) + return null; + if (!FileUtils.FILE_SCHEME.equalsIgnoreCase(uri.getScheme())) + return uri.toString(); + final String uriAsString = uri.toString(); + + // Ensure triple slash prefix + String withoutScheme = uriAsString.substring("file:".length()); // could be :/, :/// + while (withoutScheme.startsWith("/")) { + withoutScheme = withoutScheme.substring(1); + } + + // Expect leading like D:/ or d:/ on Windows + if (withoutScheme.length() >= 2 && Character.isLetter(withoutScheme.charAt(0)) && withoutScheme.charAt(1) == ':') { + final char drive = Character.toLowerCase(withoutScheme.charAt(0)); + final String rest = withoutScheme.substring(2); // drop ':' + return "file:///" + drive + "%3A" + (rest.startsWith("/") ? rest : "/" + rest); + } + + // Already in a normalized or UNC form; fall back to original + return uriAsString; + } } diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/md-lsp-proxy.js b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/md-lsp-proxy.js index 2de64f377b..53caaab146 100644 --- a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/md-lsp-proxy.js +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/md-lsp-proxy.js @@ -31,6 +31,14 @@ // server's `URI.toString()` form. This guarantees the server tracks the open document under // the key it uses during diagnostics, enabling `computeDiagnostics()` and `markdown/fs/*` requests. // +// 3) Windows path suggestions (client→server) producing incorrect absolute drive paths: +// Problem: When resolving workspace header/path completions, the current document URI and target +// document URIs sometimes differ in Windows drive case/encoding (e.g., `file:///D:/...` vs +// `file:///d%3A/...`). This causes relative path computation to fall back to odd absolute +// paths such as `../../../../d:/...`. +// Fix: Normalize the document URI in `textDocument/completion` requests so that the server sees a +// consistent Windows-encoded form and computes clean relative paths (e.g., `GUIDE.md#...`). +// // Note: On non-Windows URIs, normalization is a no-op; messages are forwarded unchanged. const { spawn } = require('child_process'); @@ -45,7 +53,8 @@ const serverArgs = process.argv.slice(3); // Launch the wrapped language server; we proxy its stdio const child = spawn(process.execPath, [serverMain, ...serverArgs], { stdio: ['pipe', 'pipe', 'inherit'] }); -// Client → Server (Problem 2): normalize Windows URIs and mirror lifecycle notifications +// Client → Server (Problem 2 & 3): normalize Windows URIs, mirror lifecycle notifications, +// and normalize completion requests let inBuffer = Buffer.alloc(0); process.stdin.on('data', chunk => { inBuffer = Buffer.concat([inBuffer, chunk]); @@ -123,15 +132,14 @@ function processInboundBuffer() { } } -// Duplicate lifecycle notifications with a normalized URI so diagnostics can run (Problem 2) +// Client → Server normalization (Problem 2 & 3) function transformInbound(bodyBuf) { try { const text = bodyBuf.toString('utf8'); const msg = JSON.parse(text); const method = msg && msg.method; - // For text document lifecycle events, also send a duplicate event - // with a normalized file URI so the server's URI.toString() lookups match. + // Duplicate lifecycle notifications with a normalized URI (Problem 2) if ((method === 'textDocument/didOpen' || method === 'textDocument/didChange' || method === 'textDocument/didClose' || method === 'textDocument/willSave' || method === 'textDocument/didSave') && msg.params && msg.params.textDocument) { const origUri = msg.params.textDocument.uri; const normUri = normalizeFileUriForServer(origUri); @@ -142,22 +150,35 @@ function transformInbound(bodyBuf) { return [Buffer.from(JSON.stringify(msg), 'utf8'), Buffer.from(JSON.stringify(dup), 'utf8')]; } } + + // Normalize completion requests so relative path suggestions are correct on Windows (Problem 3) + if (method === 'textDocument/completion' && msg.params && msg.params.textDocument) { + const origUri = msg.params.textDocument.uri; + const normUri = normalizeFileUriForServer(origUri); + if (normUri && normUri !== origUri) { + const req = structuredClone(msg); + req.params.textDocument.uri = normUri; + return [Buffer.from(JSON.stringify(req), 'utf8')]; + } + } + } catch { } return [bodyBuf]; } // Normalize Windows file URIs to match vscode-uri’s URI.toString() (Problem 2) function normalizeFileUriForServer(uri) { - if (typeof uri !== 'string' || !uri.startsWith('file:///')) - return undefined; - - // Normalize Windows drive letter and encode colon to match vscode-uri toString() - // Example: file:///D:/path -> file:///d%3A/path - const after = uri.slice('file:///'.length); + if (typeof uri !== 'string' || !uri.startsWith('file:')) return undefined; + // Strip scheme and leading slashes to get to drive letter + let after = uri.slice('file:'.length); + while (after.startsWith('/')) after = after.slice(1); + // Example accepted forms: D:/path or d:/path if (/^[A-Za-z]:/.test(after)) { const drive = after[0].toLowerCase(); - const rest = after.slice(2); // drop ":" - return 'file:///' + drive + '%3A' + rest; + const rest = after.slice(2); // drop ':' + // Ensure a leading slash for the path segment + const pathPart = rest.startsWith('/') ? rest : '/' + rest; + return 'file:///' + drive + '%3A' + pathPart; } return undefined; } diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferenceInitializer.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferenceInitializer.java index 505080f55b..bf521ac843 100644 --- a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferenceInitializer.java +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferenceInitializer.java @@ -41,6 +41,7 @@ public void initializeDefaultPreferences() { store.setDefault(MD_PREFERRED_MD_PATH_EXTENSION_STYLE, PreferredMdPathExtensionStyle.auto.value); store.setDefault(MD_SUGGEST_PATHS_ENABLED, true); store.setDefault(MD_SUGGEST_PATHS_INCLUDE_WKS_HEADER_COMPLETIONS, IncludeWorkspaceHeaderCompletions.onDoubleHash.value); + store.setDefault(MD_SUGGEST_PATHS_EXCLUDE_GLOBS, "**/node_modules/**"); /* * Validation diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferencePage.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferencePage.java index e9ddd03aa2..cc61399aac 100644 --- a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferencePage.java +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferencePage.java @@ -108,6 +108,22 @@ protected void createFieldEditors() { toLabelValueArray(IncludeWorkspaceHeaderCompletions.class), suggestionsGroup)); + final var excludeGlobs = new StringFieldEditor( + MD_SUGGEST_PATHS_EXCLUDE_GLOBS, + "Excluded path suggestion globs (comma-separated)", + suggestionsGroup); + addField(excludeGlobs); + final String excludeTooltip = """ + Glob patterns to exclude Markdown files from path suggestions. + Matched against project-relative paths, for example: + • **/node_modules/** + • docs/generated/** + • **/drafts/** + • **/*.tmp.md + """; + excludeGlobs.getLabelControl(suggestionsGroup).setToolTipText(excludeTooltip); + excludeGlobs.getTextControl(suggestionsGroup).setToolTipText(excludeTooltip); + // Validation validateEnabledEditor = new BooleanFieldEditor(MD_VALIDATE_ENABLED, "Enable validation", pageParent); addField(validateEnabledEditor); diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferences.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferences.java index b56bdd995b..878d1ade18 100644 --- a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferences.java +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/ui/preferences/MarkdownPreferences.java @@ -113,6 +113,11 @@ private ValidateEnabledForFragmentLinks(final String label) { static final String MD_SUGGEST_PATHS_INCLUDE_WKS_HEADER_COMPLETIONS = MD_SECTION + ".suggest.paths.includeWorkspaceHeaderCompletions"; // IncludeWorkspaceHeaderCompletions + + // Note: MD_SUGGEST_PATHS_EXCLUDE_GLOBS is a client-only preference used for + // filtering path suggestions on the Eclipse side and is not sent to the server. + static final String MD_SUGGEST_PATHS_EXCLUDE_GLOBS = MD_SECTION + ".suggest.paths.excludeGlobs"; // comma list + /* * Validation */ @@ -166,6 +171,22 @@ public static Settings getGlobalSettings() { return settings; } + /** + * Returns comma-separated glob patterns from preferences for excluding files from + * Markdown path suggestions. Empty or blank entries are filtered out. + */ + public static String[] getSuggestPathsExcludeGlobs() { + final IPreferenceStore store = Activator.getDefault().getPreferenceStore(); + final String raw = store.getString(MD_SUGGEST_PATHS_EXCLUDE_GLOBS); + if (raw == null || raw.trim().isEmpty()) { + return new String[0]; + } + return Arrays.stream(raw.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toArray(String[]::new); + } + public static boolean isMatchMarkdownSection(final String section) { return isMatchSection(section, MD_SECTION); }