Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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<LanguageServerWrapper>();
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");
}
}
3 changes: 2 additions & 1 deletion org.eclipse.wildwebdeveloper/META-INF/MANIFEST.MF
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String, Object>();
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;
Expand Down Expand Up @@ -212,7 +214,7 @@ public CompletableFuture<List<Map<String, Object>>> parseMarkdown(final Map<Stri
}
}
if (!hasText && uriPath == null) {
return Collections.emptyList();
return List.of();
}
final String argPath;
if (hasText) {
Expand All @@ -229,7 +231,7 @@ public CompletableFuture<List<Map<String, Object>>> parseMarkdown(final Map<Stri
if (exit != 0) {
final var err = new String(proc.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
ILog.get().warn("markdown-it parser failed (" + exit + "): " + err, null);
return Collections.emptyList();
return List.of();
}
final var gson = new Gson();
final List<Map<String, Object>> tokens = gson.fromJson(out, List.class);
Expand All @@ -242,7 +244,7 @@ public CompletableFuture<List<Map<String, Object>>> parseMarkdown(final Map<Stri
}
} catch (final Exception ignore) {
}
return tokens != null ? tokens : Collections.emptyList();
return tokens != null ? tokens : List.of();
} catch (final InterruptedException ex) {
ILog.get().warn(ex.getMessage(), ex);
/* Clean up whatever needs to be handled before interrupting */
Expand All @@ -256,7 +258,7 @@ public CompletableFuture<List<Map<String, Object>>> parseMarkdown(final Map<Stri
} catch (final Exception ignore) {
}
}
return Collections.emptyList();
return List.of();
});
}

Expand All @@ -270,19 +272,27 @@ public CompletableFuture<List<Map<String, Object>>> parseMarkdown(final Map<Stri
public CompletableFuture<List<String>> findMarkdownFilesInWorkspace(final Object unused) {
return CompletableFuture.supplyAsync(() -> {
final var uris = new ArrayList<String>();
// Compile exclude globs from preferences once per request
final String[] excludeGlobs = MarkdownPreferences.getSuggestPathsExcludeGlobs();
final List<PathMatcher> 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
}
Expand All @@ -295,10 +305,13 @@ public CompletableFuture<List<String>> 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
}
Expand All @@ -312,6 +325,48 @@ public CompletableFuture<List<String>> findMarkdownFilesInWorkspace(final Object
});
}

private static List<PathMatcher> compileGlobMatchers(String... globs) {
if (globs == null || globs.length == 0)
return List.of();

var fs = FileSystems.getDefault();
var matchers = new ArrayList<PathMatcher>();

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<String> 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<PathMatcher> 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;
}

/**
* <pre>
* Request: { uri: string }
Expand Down Expand Up @@ -409,10 +464,10 @@ public CompletableFuture<Void> fsWatcherCreate(final Map<String, Object> params)
@SuppressWarnings("unchecked")
final Map<String, Object> options = params.get("options") instanceof Map //
? (Map<String, Object>) 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);
Expand All @@ -439,4 +494,35 @@ public CompletableFuture<Void> fsWatcherDelete(final Map<String, Object> params)
return null;
});
}

/**
* Normalize Windows file URIs to match vscode-uri's URI.toString() form used by the server.
* <br>
* Examples:
* <li>file:/D:/path -> file:///d%3A/path
* <li>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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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]);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
Loading