From 7b427a54d9ca5e0c7a89d04f907db6c52d82cccf Mon Sep 17 00:00:00 2001 From: sebthom Date: Sun, 2 Nov 2025 16:20:59 +0100 Subject: [PATCH 1/3] fix: Markdown LS returns unnormalized path for completions on windows --- .../markdown/MarkdownLanguageClient.java | 37 +++++++++++++-- .../wildwebdeveloper/markdown/md-lsp-proxy.js | 45 ++++++++++++++----- 2 files changed, 67 insertions(+), 15 deletions(-) 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..f693bbe665 100644 --- a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java @@ -140,7 +140,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; @@ -282,7 +282,7 @@ public CompletableFuture> findMarkdownFilesInWorkspace(final Object 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()); + uris.add(normalizeFileUriForLanguageServer(res.getLocationURI())); } return false; // no children } @@ -298,7 +298,7 @@ public CompletableFuture> findMarkdownFilesInWorkspace(final Object 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()); + uris.add(normalizeFileUriForLanguageServer(res.getLocationURI())); } return false; // no children } @@ -439,4 +439,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; } From a90511daa9525fd2de042a94d67debf764ca41fb Mon Sep 17 00:00:00 2001 From: sebthom Date: Sun, 2 Nov 2025 19:29:43 +0100 Subject: [PATCH 2/3] fix: exclude derived/hidden files/folders from path suggestions --- .../wildwebdeveloper/markdown/MarkdownLanguageClient.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 f693bbe665..b89d94619d 100644 --- a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java +++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownLanguageClient.java @@ -275,10 +275,14 @@ public CompletableFuture> findMarkdownFilesInWorkspace(final Object 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")) { @@ -295,6 +299,8 @@ 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")) { From 5508e55876075a560c179754551f71f5dcb80b73 Mon Sep 17 00:00:00 2001 From: sebthom Date: Wed, 5 Nov 2025 22:25:11 +0100 Subject: [PATCH 3/3] feat: add "Excluded path suggestion globs" preference option --- .../wildwebdeveloper/tests/TestMarkdown.java | 62 +++++++++++++++++ .../META-INF/MANIFEST.MF | 3 +- .../markdown/MarkdownLanguageClient.java | 69 ++++++++++++++++--- .../MarkdownPreferenceInitializer.java | 1 + .../preferences/MarkdownPreferencePage.java | 16 +++++ .../ui/preferences/MarkdownPreferences.java | 21 ++++++ 6 files changed, 161 insertions(+), 11 deletions(-) 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 b89d94619d..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; @@ -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()) { @@ -285,7 +290,8 @@ public CompletableFuture> findMarkdownFilesInWorkspace(final Object return false; if (res.getType() == IResource.FILE) { final String name = res.getName().toLowerCase(); - if (name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) { + if ((name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) + && !isExcludedByGlobs(res, excludeMatchers)) { uris.add(normalizeFileUriForLanguageServer(res.getLocationURI())); } return false; // no children @@ -303,7 +309,8 @@ public CompletableFuture> findMarkdownFilesInWorkspace(final Object return false; if (res.getType() == IResource.FILE) { final String name = res.getName().toLowerCase(); - if (name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) { + if ((name.endsWith(".md") || name.endsWith(".markdown") || name.endsWith(".mdown")) + && !isExcludedByGlobs(res, excludeMatchers)) { uris.add(normalizeFileUriForLanguageServer(res.getLocationURI())); } return false; // no children @@ -318,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 }
    @@ -415,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);
    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);
     	}