diff --git a/org.eclipse.wildwebdeveloper.tests/META-INF/MANIFEST.MF b/org.eclipse.wildwebdeveloper.tests/META-INF/MANIFEST.MF
index aeb4a10cb3..7691d87085 100644
--- a/org.eclipse.wildwebdeveloper.tests/META-INF/MANIFEST.MF
+++ b/org.eclipse.wildwebdeveloper.tests/META-INF/MANIFEST.MF
@@ -2,13 +2,14 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: WWD Tests
Bundle-SymbolicName: org.eclipse.wildwebdeveloper.tests;singleton:=true
-Bundle-Version: 1.0.18.qualifier
+Bundle-Version: 1.0.19.qualifier
Bundle-License: EPL-2.0;link="http://www.eclipse.org/legal/epl-2.0"
Bundle-Vendor: Eclipse Wild Web Developer
Automatic-Module-Name: org.eclipse.wildwebdeveloper.tests
Bundle-RequiredExecutionEnvironment: JavaSE-21
Require-Bundle: org.eclipse.wildwebdeveloper;bundle-version="1.0.0",
junit-jupiter-api;bundle-version="[6.0.0,7.0.0)",
+ junit-platform-engine;bundle-version="[6.0.0,7.0.0)",
org.eclipse.core.resources,
org.eclipse.core.runtime,
org.eclipse.ui.ide,
diff --git a/org.eclipse.wildwebdeveloper.tests/pom.xml b/org.eclipse.wildwebdeveloper.tests/pom.xml
index a07b2bcfb3..64f142731e 100644
--- a/org.eclipse.wildwebdeveloper.tests/pom.xml
+++ b/org.eclipse.wildwebdeveloper.tests/pom.xml
@@ -7,7 +7,7 @@
1.0.0-SNAPSHOT
eclipse-test-plugin
- 1.0.18-SNAPSHOT
+ 1.0.19-SNAPSHOT
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 fb24ee083f..5cdfc58c84 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
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2025 Vegard IT GmbH and others.
+ * Copyright (c) 2025, 2026 Vegard IT GmbH and others.
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
@@ -14,6 +14,7 @@
import static org.eclipse.core.resources.IMarker.*;
import static org.eclipse.wildwebdeveloper.markdown.MarkdownDiagnosticsManager.MARKDOWN_MARKER_TYPE;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.InvocationHandler;
@@ -22,27 +23,39 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.List;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;
import java.util.stream.Collectors;
import org.eclipse.core.filebuffers.FileBuffers;
+import org.eclipse.core.filebuffers.ITextFileBuffer;
import org.eclipse.core.filebuffers.LocationKind;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IMarker;
import org.eclipse.core.resources.IResource;
+import org.eclipse.core.resources.IResourceChangeEvent;
+import org.eclipse.core.resources.IResourceChangeListener;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
+import org.eclipse.core.runtime.jobs.Job;
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.lsp4j.Diagnostic;
+import org.eclipse.lsp4j.DiagnosticSeverity;
import org.eclipse.lsp4j.DocumentDiagnosticParams;
import org.eclipse.lsp4j.DocumentDiagnosticReport;
+import org.eclipse.lsp4j.Position;
+import org.eclipse.lsp4j.Range;
+import org.eclipse.lsp4j.RelatedFullDocumentDiagnosticReport;
import org.eclipse.lsp4j.services.LanguageServer;
import org.eclipse.ui.PlatformUI;
import org.eclipse.ui.editors.text.TextEditor;
@@ -56,6 +69,9 @@
record MarkdownTest(String markdown, String messagePattern, int severity) {
}
+/**
+ * Verifies Markdown language features and the lifecycle of their workspace problem markers.
+ */
@ExtendWith(AllCleanRule.class)
class TestMarkdown {
@@ -83,11 +99,11 @@ private static DiagnosticSpy newDiagnosticSpy() {
});
final InvocationHandler serverHandler = (proxy, method, args) -> (switch (method.getName()) {
- case "getTextDocumentService" -> textDocumentService;
- case "getWorkspaceService" -> null;
- case "initialize", "shutdown" -> CompletableFuture.completedFuture(null);
- case "exit" -> null;
- default -> null;
+ case "getTextDocumentService" -> textDocumentService;
+ case "getWorkspaceService" -> null;
+ case "initialize", "shutdown" -> CompletableFuture.completedFuture(null);
+ case "exit" -> null;
+ default -> null;
});
final var server = (LanguageServer) Proxy.newProxyInstance(TestMarkdown.class.getClassLoader(),
@@ -106,6 +122,301 @@ private static boolean waitUpTo(final long timeoutMs, final BooleanSupplier cond
return condition.getAsBoolean();
}
+ private IFile createDiagnosticFile() throws Exception {
+ // Register the buffer listener before opening a buffer, without starting a real language server.
+ Class.forName(MarkdownDiagnosticsManager.class.getName());
+ final var project = ResourcesPlugin.getWorkspace().getRoot()
+ .getProject(getClass().getName() + ".lifecycle." + System.nanoTime());
+ project.create(null);
+ project.open(null);
+ final var file = project.getFile("doc.md");
+ file.create("# Title\nBody\n".getBytes(StandardCharsets.UTF_8), true, false, null);
+ return file;
+ }
+
+ private static CompletableFuture> requestDiagnostics(final IFile file, final DiagnosticSpy spy) throws Exception {
+ // Inject a controllable server at the existing request boundary; no production test API is needed.
+ final var method = MarkdownDiagnosticsManager.class.getDeclaredMethod("refreshFile", IFile.class,
+ LanguageServer.class, ITextFileBuffer.class, boolean.class);
+ method.setAccessible(true);
+ return (CompletableFuture>) method.invoke(null, file, spy.server(),
+ FileBuffers.getTextFileBufferManager().getTextFileBuffer(file.getFullPath(), LocationKind.IFILE), false);
+ }
+
+ private static DocumentDiagnosticReport diagnosticReport(final String... messages) {
+ final var diagnostics = new ArrayList();
+ for (final String message : messages) {
+ final var diagnostic = new Diagnostic();
+ diagnostic.setMessage(message);
+ diagnostic.setSeverity(DiagnosticSeverity.Warning);
+ diagnostic.setRange(new Range(new Position(1, 0), new Position(1, 4)));
+ diagnostics.add(diagnostic);
+ }
+ final var report = new RelatedFullDocumentDiagnosticReport();
+ report.setItems(diagnostics);
+ return new DocumentDiagnosticReport(report);
+ }
+
+ private static void awaitDiagnosticMarkers() {
+ assertTrue(DisplayHelper.waitForCondition(PlatformUI.getWorkbench().getDisplay(), 5_000,
+ () -> Job.getJobManager().find(MarkdownDiagnosticsManager.class).length == 0),
+ "Markdown marker jobs did not finish");
+ }
+
+ private static void awaitDiagnosticRefresh(final CompletableFuture> refresh) {
+ // Jobs leave the manager before their done listeners complete the refresh future.
+ assertTrue(DisplayHelper.waitForCondition(PlatformUI.getWorkbench().getDisplay(), 5_000, refresh::isDone),
+ "Markdown diagnostic refresh did not finish");
+ refresh.join();
+ }
+
+ private static List diagnosticMessages(final IFile file) throws CoreException {
+ final var messages = new ArrayList();
+ for (final var marker : file.findMarkers(MARKDOWN_MARKER_TYPE, true, IResource.DEPTH_ZERO)) {
+ messages.add(marker.getAttribute(IMarker.MESSAGE, ""));
+ }
+ Collections.sort(messages);
+ return messages;
+ }
+
+ @Test
+ void disposingMarkdownBufferDoesNotWaitForWorkspaceNotification() throws Exception {
+ final var file = createDiagnosticFile();
+ final var workspace = ResourcesPlugin.getWorkspace();
+ final var manager = FileBuffers.getTextFileBufferManager();
+ manager.connect(file.getFullPath(), LocationKind.IFILE, null);
+ // Two viewers may share the file; only the last disconnect disposes its buffer.
+ manager.connect(file.getFullPath(), LocationKind.IFILE, null);
+ final var markdownMarker = file.createMarker(MARKDOWN_MARKER_TYPE);
+ final var otherMarker = file.createMarker(IMarker.PROBLEM);
+ final var notificationEntered = new CountDownLatch(1);
+ final var releaseNotification = new CountDownLatch(1);
+ final var disposalReturned = new CountDownLatch(1);
+ final IResourceChangeListener listener = event -> {
+ if (event.getDelta() == null || event.getDelta().findMember(file.getFullPath()) == null)
+ return;
+ notificationEntered.countDown();
+ try {
+ releaseNotification.await(10, TimeUnit.SECONDS);
+ } catch (final InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ }
+ };
+ workspace.addResourceChangeListener(listener, IResourceChangeEvent.POST_CHANGE);
+ final var notificationJob = Job.create("Hold a resource change notification", monitor -> {
+ otherMarker.setAttribute(IMarker.MESSAGE, "Trigger POST_CHANGE");
+ });
+ try {
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ assertTrue(markdownMarker.exists(), "A remaining buffer consumer must retain Markdown markers");
+ notificationJob.schedule();
+ assertTrue(notificationEntered.await(5, TimeUnit.SECONDS), "Resource notification did not start");
+ // Release the workspace independently even when the old implementation blocks the UI thread.
+ // The assertion checks ordering, rather than relying on how long disconnect normally takes.
+ final var returnedBeforeRelease = CompletableFuture.supplyAsync(() -> {
+ try {
+ return disposalReturned.await(5, TimeUnit.SECONDS);
+ } catch (final InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ return false;
+ } finally {
+ releaseNotification.countDown();
+ }
+ });
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ disposalReturned.countDown();
+ assertTrue(returnedBeforeRelease.get(10, TimeUnit.SECONDS),
+ "Buffer disposal waited for workspace access on the UI thread");
+ awaitDiagnosticMarkers();
+ assertTrue(!markdownMarker.exists(), "Closing the buffer must eventually remove Markdown markers");
+ assertTrue(otherMarker.exists(), "Cleanup must preserve other marker types");
+ } finally {
+ releaseNotification.countDown();
+ workspace.removeResourceChangeListener(listener);
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ assertTrue(DisplayHelper.waitForCondition(PlatformUI.getWorkbench().getDisplay(), 5_000,
+ () -> notificationJob.getState() == Job.NONE), "Resource notification did not finish");
+ }
+ }
+
+ @Test
+ void reopeningMarkdownBufferPreservesMarkersFromItsNewSession() throws Exception {
+ final var file = createDiagnosticFile();
+ final var manager = FileBuffers.getTextFileBufferManager();
+ manager.connect(file.getFullPath(), LocationKind.IFILE, null);
+ final var jobs = Job.getJobManager();
+ try {
+ // Queue close cleanup but reopen before it can run, as compare-editor input replacement can do.
+ jobs.suspend();
+ try {
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ manager.connect(file.getFullPath(), LocationKind.IFILE, null);
+ file.createMarker(MARKDOWN_MARKER_TYPE).setAttribute(IMarker.MESSAGE, "fresh");
+ } finally {
+ jobs.resume();
+ }
+ awaitDiagnosticMarkers();
+ assertEquals(List.of("fresh"), diagnosticMessages(file));
+ } finally {
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ awaitDiagnosticMarkers();
+ }
+ }
+
+ @Test
+ void lateMarkdownDiagnosticsDoNotRestoreMarkersAfterClose() throws Exception {
+ final var file = createDiagnosticFile();
+ final var manager = FileBuffers.getTextFileBufferManager();
+ manager.connect(file.getFullPath(), LocationKind.IFILE, null);
+ final var spy = newDiagnosticSpy();
+ try {
+ requestDiagnostics(file, spy);
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ awaitDiagnosticMarkers();
+ spy.lastFuture().get().complete(diagnosticReport("stale"));
+ awaitDiagnosticMarkers();
+ assertEquals(List.of(), diagnosticMessages(file));
+ } finally {
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ }
+ }
+
+ @Test
+ void reopeningMarkdownBufferStartsFreshDiagnosticsWhileOldRequestIsPending() throws Exception {
+ final var file = createDiagnosticFile();
+ final var manager = FileBuffers.getTextFileBufferManager();
+ manager.connect(file.getFullPath(), LocationKind.IFILE, null);
+ final var spy = newDiagnosticSpy();
+ CompletableFuture oldRequest = null;
+ try {
+ requestDiagnostics(file, spy);
+ oldRequest = spy.lastFuture().get();
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ manager.connect(file.getFullPath(), LocationKind.IFILE, null);
+ requestDiagnostics(file, spy);
+ assertEquals(2, spy.calls().get(), "An old session must not suppress a new session's diagnostic request");
+ spy.lastFuture().get().complete(diagnosticReport("fresh"));
+ awaitDiagnosticMarkers();
+ oldRequest.complete(diagnosticReport("stale"));
+ awaitDiagnosticMarkers();
+ assertEquals(List.of("fresh"), diagnosticMessages(file));
+ } finally {
+ if (oldRequest != null)
+ oldRequest.complete(null);
+ if (spy.lastFuture().get() != null)
+ spy.lastFuture().get().complete(null);
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ awaitDiagnosticMarkers();
+ }
+ }
+
+ @Test
+ void markdownDiagnosticsForClosedFilesKeepAllMarkers() throws Exception {
+ final var file = createDiagnosticFile();
+ final var spy = newDiagnosticSpy();
+ requestDiagnostics(file, spy);
+ // Each range used to open and dispose a shared buffer, deleting markers from earlier ranges.
+ spy.lastFuture().get().complete(diagnosticReport("first", "second"));
+ awaitDiagnosticMarkers();
+ assertEquals(List.of("first", "second"), diagnosticMessages(file));
+ assertEquals(null, FileBuffers.getTextFileBufferManager().getTextFileBuffer(file.getFullPath(), LocationKind.IFILE));
+ }
+
+ @Test
+ void markdownRefreshStaysInFlightUntilMarkersAreApplied() throws Exception {
+ final var file = createDiagnosticFile();
+ final var spy = newDiagnosticSpy();
+ final var jobs = Job.getJobManager();
+ final CompletableFuture> refresh;
+ // A completed LS response must still suppress duplicate pulls while its marker write is queued.
+ jobs.suspend();
+ try {
+ refresh = requestDiagnostics(file, spy);
+ spy.lastFuture().get().complete(diagnosticReport("queued"));
+ requestDiagnostics(file, spy);
+ assertEquals(1, spy.calls().get());
+ } finally {
+ jobs.resume();
+ }
+ awaitDiagnosticRefresh(refresh);
+ assertEquals(List.of("queued"), diagnosticMessages(file));
+ requestDiagnostics(file, spy);
+ assertEquals(2, spy.calls().get(), "A completed marker write must allow the next refresh");
+ spy.lastFuture().get().complete(null);
+ }
+
+ @Test
+ void serverRefreshDuringQueuedMarkdownMarkersRunsAgain() throws Exception {
+ final var file = createDiagnosticFile();
+ final var manager = FileBuffers.getTextFileBufferManager();
+ manager.connect(file.getFullPath(), LocationKind.IFILE, null);
+ final var spy = newDiagnosticSpy();
+ final var jobs = Job.getJobManager();
+ Job markerJob = null;
+ try {
+ jobs.suspend();
+ try {
+ requestDiagnostics(file, spy);
+ spy.lastFuture().get().complete(diagnosticReport("stale"));
+ final var markerJobs = jobs.find(MarkdownDiagnosticsManager.class);
+ assertEquals(1, markerJobs.length);
+ markerJob = markerJobs[0];
+ // Hold only marker application so the server's debounced refresh can still run.
+ assertTrue(markerJob.sleep(), "Marker application must remain queued");
+ } finally {
+ jobs.resume();
+ }
+ for (int refresh = 0; refresh < 3; refresh++) {
+ MarkdownDiagnosticsManager.refreshAllOpenMarkdownFiles(spy.server());
+ final var field = MarkdownDiagnosticsManager.class.getDeclaredField("REFRESH_JOB");
+ field.setAccessible(true);
+ // Await consumption of each invalidation, not merely expiration of its debounce delay.
+ ((Job) field.get(null)).join();
+ }
+ assertEquals(1, spy.calls().get(), "Server invalidations must not overlap the active refresh");
+ final var firstResponse = spy.lastFuture().get();
+ markerJob.wakeUp();
+ assertTrue(waitUpTo(5_000, () -> spy.lastFuture().get() != firstResponse),
+ "A server invalidation received during marker application must trigger a follow-up pull");
+ assertEquals(2, spy.calls().get(), "Pending server invalidations must coalesce into one follow-up pull");
+ // Observe the active follow-up through the same boundary used by opportunistic parser pulls.
+ // This also ensures its response handler is attached before the test completes that response.
+ final var followUp = requestDiagnostics(file, spy);
+ spy.lastFuture().get().complete(diagnosticReport("fresh"));
+ awaitDiagnosticRefresh(followUp);
+ assertEquals(2, spy.calls().get(), "An opportunistic pull must not request another follow-up");
+ assertEquals(List.of("fresh"), diagnosticMessages(file));
+ } finally {
+ if (markerJob != null)
+ markerJob.wakeUp();
+ if (spy.lastFuture().get() != null)
+ spy.lastFuture().get().complete(null);
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ awaitDiagnosticMarkers();
+ }
+ }
+
+ @Test
+ void markdownDiagnosticOffsetsUseUnsavedEditorContent() throws Exception {
+ final var file = createDiagnosticFile();
+ final var manager = FileBuffers.getTextFileBufferManager();
+ manager.connect(file.getFullPath(), LocationKind.IFILE, null);
+ try {
+ final var document = manager.getTextFileBuffer(file.getFullPath(), LocationKind.IFILE).getDocument();
+ document.set("# A longer unsaved title\nBody\n");
+ final var spy = newDiagnosticSpy();
+ requestDiagnostics(file, spy);
+ spy.lastFuture().get().complete(diagnosticReport("unsaved"));
+ awaitDiagnosticMarkers();
+ final var markers = file.findMarkers(MARKDOWN_MARKER_TYPE, true, IResource.DEPTH_ZERO);
+ assertEquals(1, markers.length);
+ assertEquals(document.getLineOffset(1), markers[0].getAttribute(IMarker.CHAR_START, -1));
+ } finally {
+ manager.disconnect(file.getFullPath(), LocationKind.IFILE, null);
+ awaitDiagnosticMarkers();
+ }
+ }
+
@Test
void refreshDiagnosticsDoesNothingWhenNoMarkdownBuffersOpen() throws Exception {
final var project = ResourcesPlugin.getWorkspace().getRoot()
@@ -159,6 +470,7 @@ void refreshDiagnosticsIsDedupedWhileInFlight() throws Exception {
}
@Test
+ @SuppressWarnings("restriction")
void diagnosticsCoverTypicalMarkdownIssues() throws Exception {
var project = ResourcesPlugin.getWorkspace().getRoot().getProject(getClass().getName() + System.nanoTime());
project.create(null);
@@ -240,6 +552,7 @@ void diagnosticsCoverTypicalMarkdownIssues() throws Exception {
}
@Test
+ @SuppressWarnings("restriction")
void workspaceHeaderCompletionsRespectExcludeGlobs() throws Exception {
var project = ResourcesPlugin.getWorkspace().getRoot().getProject(getClass().getName() + ".hdr" + System.nanoTime());
project.create(null);
diff --git a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownDiagnosticsManager.java b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownDiagnosticsManager.java
index b6e8ca5123..4c8a324ad6 100644
--- a/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownDiagnosticsManager.java
+++ b/org.eclipse.wildwebdeveloper/src/org/eclipse/wildwebdeveloper/markdown/MarkdownDiagnosticsManager.java
@@ -22,16 +22,20 @@
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import org.eclipse.core.filebuffers.FileBuffers;
import org.eclipse.core.filebuffers.IFileBuffer;
import org.eclipse.core.filebuffers.IFileBufferListener;
+import org.eclipse.core.filebuffers.ITextFileBuffer;
import org.eclipse.core.filebuffers.LocationKind;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IMarker;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.ResourcesPlugin;
+import org.eclipse.core.resources.WorkspaceJob;
import org.eclipse.core.runtime.CoreException;
+import org.eclipse.core.runtime.ICoreRunnable;
import org.eclipse.core.runtime.ILog;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IProgressMonitor;
@@ -43,6 +47,7 @@
import org.eclipse.core.runtime.jobs.IJobChangeEvent;
import org.eclipse.core.runtime.jobs.Job;
import org.eclipse.core.runtime.jobs.JobChangeAdapter;
+import org.eclipse.core.runtime.jobs.JobGroup;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.IDocument;
import org.eclipse.lsp4e.LanguageServers;
@@ -62,6 +67,7 @@
/**
* Pulls diagnostics from the Markdown language server and maps them to Eclipse problem markers.
+ * Marker writes run in background workspace jobs so buffer disposal does not wait for workspace access.
*/
public final class MarkdownDiagnosticsManager {
@@ -73,8 +79,25 @@ public final class MarkdownDiagnosticsManager {
private static final Set OPEN_MARKDOWN_FILES = ConcurrentHashMap.newKeySet();
- /** De-dupes diagnostic pulls so repeated refresh requests do not start overlapping diagnostics for the same file/server */
- private static final ConcurrentHashMap> IN_FLIGHT_REFRESHES = new ConcurrentHashMap<>();
+ /** Tracks a buffer's refresh through marker writes and coalesces later server invalidations. */
+ private record DiagnosticRefresh(ITextFileBuffer buffer, CompletableFuture completion, AtomicBoolean invalidated) {
+ }
+
+ /** De-dupes diagnostic pulls for the same file/server without suppressing a reopened buffer's first request. */
+ private static final ConcurrentHashMap IN_FLIGHT_REFRESHES = new ConcurrentHashMap<>();
+
+ /**
+ * Prevents diagnostic updates and marker deletion after buffer disposal
+ * from running concurrently, even when the workspace marker rule is null.
+ */
+ private static final JobGroup MARKER_JOBS = new JobGroup("Wild Web Developer Markdown markers", 1, 0) {
+ @Override
+ protected boolean shouldCancel(final IStatus lastCompletedJobResult, final int numberOfFailedJobs,
+ final int numberOfCanceledJobs) {
+ // A failure for one file must not cancel pending marker work for other files.
+ return false;
+ }
+ };
/** Servers that requested a refresh since the last debounce run (identity-based: some LS proxies do not implement hashCode()) */
private static final Set PENDING_REFRESH_SERVERS = Collections.newSetFromMap(new IdentityHashMap<>());
@@ -176,10 +199,16 @@ public void bufferDisposed(final IFileBuffer buffer) {
OPEN_MARKDOWN_FILES.remove(file);
- /*
- * remove all problem markers on editor close
- */
- clearMarkers(file);
+ // Compare-editor input replacement can dispose buffers on the UI thread while a
+ // resource notification owns the workspace. Never wait for marker writes here.
+ scheduleMarkerUpdate(file, "Clear Markdown diagnostics", monitor -> {
+ // A delayed close must not erase markers belonging to a reopened buffer.
+ if (file.isAccessible() && getSharedBuffer(file) == null)
+ clearMarkers(file);
+ }).exceptionally(ex -> {
+ ILog.get().warn(ex.getMessage(), ex);
+ return null;
+ });
} catch (Exception ex) {
ILog.get().warn(ex.getMessage(), ex);
}
@@ -241,7 +270,7 @@ private static String markerKey(final IMarker marker) throws CoreException {
return markerKey(message, severity, charStart, charEnd);
}
- private static synchronized void applyMarkers(final IFile file, final List diagnostics) {
+ private static void applyMarkers(final IFile file, final List diagnostics) {
try {
final var markdownMarkers = new HashMap();
for (final IMarker m : file.findMarkers(MARKDOWN_MARKER_TYPE, true, IResource.DEPTH_ZERO)) {
@@ -326,18 +355,57 @@ private static List extractDiagnostics(final RelatedFullDocumentDiag
return out;
}
- private static void handleDiagnosticReport(final IFile file, final DocumentDiagnosticReport report) {
- if (file == null || !file.exists() || report == null)
- return;
+ private static CompletableFuture scheduleMarkerUpdate(final IFile file, final String name,
+ final ICoreRunnable update) {
+ final var completion = new CompletableFuture();
+ final var job = new WorkspaceJob(name) {
+ @Override
+ public IStatus runInWorkspace(final IProgressMonitor monitor) throws CoreException {
+ update.run(monitor);
+ return Status.OK_STATUS;
+ }
- if (report.isRight()) {
- // Unchanged for the main document: do not touch markers
- return;
- }
+ @Override
+ public boolean belongsTo(final Object family) {
+ return family == MarkdownDiagnosticsManager.class;
+ }
+ };
+ job.setRule(ResourcesPlugin.getWorkspace().getRuleFactory().markerRule(file));
+ job.setJobGroup(MARKER_JOBS);
+ job.setSystem(true);
+ job.addJobChangeListener(new JobChangeAdapter() {
+ @Override
+ public void done(final IJobChangeEvent event) {
+ // Finish the request after the workspace job, including cancellation or
+ // failure.
+ if (event.getResult().isOK())
+ completion.complete(null);
+ else
+ completion.completeExceptionally(new CoreException(event.getResult()));
+ }
+ });
+ job.schedule();
+ return completion;
+ }
- if (report.isLeft()) {
- applyMarkers(file, extractDiagnostics(report.getLeft()));
- }
+ private static ITextFileBuffer getSharedBuffer(final IFile file) {
+ return FileBuffers.getTextFileBufferManager().getTextFileBuffer(file.getFullPath(), LocationKind.IFILE);
+ }
+
+ private static CompletableFuture handleDiagnosticReport(final IFile file, final ITextFileBuffer requestBuffer,
+ final DocumentDiagnosticReport report) {
+ // An unchanged report retains the current markers.
+ if (report == null || !report.isLeft())
+ return CompletableFuture.completedFuture(null);
+
+ return scheduleMarkerUpdate(file, "Update Markdown diagnostics", monitor -> {
+ // Check at execution time: a queued response must not outlive its buffer
+ // session.
+ // Null still permits explicit pulls for unopened files without creating an
+ // editor buffer.
+ if (file.isAccessible() && getSharedBuffer(file) == requestBuffer)
+ applyMarkers(file, extractDiagnostics(report.getLeft()));
+ });
}
private static void scheduleRefreshAllOpenMarkdownFiles(final LanguageServer languageServer) {
@@ -371,7 +439,11 @@ protected IStatus run(final IProgressMonitor monitor) {
for (final IFile file : openFiles) {
if (monitor.isCanceled())
break;
- refreshFile(file, ls);
+ final var buffer = getSharedBuffer(file);
+ // A file closed since the snapshot is not an explicit unopened-file pull.
+ if (buffer != null) {
+ refreshFile(file, ls, buffer, true);
+ }
}
}
return Status.OK_STATUS;
@@ -404,8 +476,10 @@ public void done(final IJobChangeEvent event) {
// Debounce: keep only the latest refresh request.
//
- // Avoid (re-)scheduling while RUNNING; schedule() would throw IllegalStateException.
- // Instead, mark pending and let the JobChangeListener reschedule once it completes.
+ // Avoid (re-)scheduling while RUNNING; schedule() would throw
+ // IllegalStateException.
+ // Instead, mark pending and let the JobChangeListener reschedule once it
+ // completes.
if (REFRESH_JOB.getState() == Job.RUNNING) {
REFRESH_RESCHEDULE_REQUESTED = true;
return;
@@ -429,42 +503,64 @@ public static void refreshFile(final IFile file) {
if (file == null || !file.exists())
return;
+ // Keep the original session even if server discovery completes after close/reopen.
+ final var buffer = getSharedBuffer(file);
+
LanguageServers.forProject(file.getProject())
.withPreferredServer(
LanguageServersRegistry.getInstance().getDefinition(MarkdownLanguageServer.MARKDOWN_LANGUAGE_SERVER_ID))
.excludeInactive()
.collectAll((w, ls) -> CompletableFuture.completedFuture(ls))
- .thenAccept(lss -> lss.forEach(ls -> refreshFile(file, ls)));
+ .thenAccept(lss -> lss.forEach(ls -> refreshFile(file, ls, buffer, false)));
} catch (final Exception ex) {
ILog.get().warn(ex.getMessage(), ex);
}
}
- private static void refreshFile(final IFile file, final LanguageServer languageServer) {
+ private static CompletableFuture refreshFile(final IFile file, final LanguageServer languageServer,
+ final ITextFileBuffer requestBuffer, final boolean serverInvalidation) {
if (file == null || !file.exists() || languageServer == null)
- return;
+ return CompletableFuture.completedFuture(null);
+ if (getSharedBuffer(file) != requestBuffer)
+ return CompletableFuture.completedFuture(null);
// Include language server identity so de-duping does not hide refreshes across different server instances.
final String key = file.getFullPath().toString() + "@" + System.identityHashCode(languageServer);
- IN_FLIGHT_REFRESHES.compute(key, (k, existing) -> {
- if (existing != null && !existing.isDone())
+ final var refresh = IN_FLIGHT_REFRESHES.compute(key, (k, existing) -> {
+ // Reopening must bypass an old session's request even while that request is pending.
+ if (existing != null && existing.buffer() == requestBuffer && !existing.completion().isDone()) {
+ // A server invalidation needs fresh diagnostics after the current request. Parser calls can
+ // be caused by that request itself, so retrying those would create a diagnostic loop.
+ if (serverInvalidation)
+ existing.invalidated().set(true);
return existing;
+ }
final String uri = toLspFileUri(file);
final var params = new DocumentDiagnosticParams();
params.setTextDocument(new TextDocumentIdentifier(uri));
- final CompletableFuture started = languageServer.getTextDocumentService()
- .diagnostic(params)
+ final CompletableFuture started = languageServer.getTextDocumentService().diagnostic(params)
.orTimeout(DIAGNOSTICS_TIMEOUT_SECONDS, TimeUnit.SECONDS)
- .thenAccept(report -> handleDiagnosticReport(file, report))
- .exceptionally(ex -> {
+ // Keep de-duplication active while marker work is queued, not just during the LS call.
+ .thenCompose(report -> handleDiagnosticReport(file, requestBuffer, report)).exceptionally(ex -> {
ILog.get().warn(ex.getMessage(), ex);
return null;
});
- started.whenComplete((v, ex) -> IN_FLIGHT_REFRESHES.remove(k, started));
- return started;
+ return new DiagnosticRefresh(requestBuffer, started, new AtomicBoolean());
+ });
+ // Register outside compute: an already-completed response must not recursively update the map.
+ // Conditional removal protects newer sessions and lets only one caller start the coalesced follow-up.
+ return refresh.completion().whenComplete((v, ex) -> {
+ if (IN_FLIGHT_REFRESHES.remove(key, refresh) && refresh.invalidated().get()) {
+ // Reuse the captured buffer so the entry check discards invalidations after close/reopen.
+ refreshFile(file, languageServer, requestBuffer, false);
+ }
+ }).exceptionally(ex -> {
+ // Starting the follow-up can throw before a response future is returned.
+ ILog.get().warn(ex.getMessage(), ex);
+ return null;
});
}
@@ -489,30 +585,38 @@ private static String toLspFileUri(final IFile file) {
}
private static int[] toOffsets(final IFile file, final Range range) throws CoreException, BadLocationException {
- // Connect ensures a document is available even if no editor is open
- final var mgr = FileBuffers.getTextFileBufferManager();
+ // Use the live document so offsets include unsaved editor changes, without taking ownership.
+ final var sharedBuffer = getSharedBuffer(file);
+ if (sharedBuffer != null)
+ return toOffsets(sharedBuffer.getDocument(), range);
+
+ // Temporary reads must not fire shared-buffer lifecycle events and schedule cleanup of
+ // the markers being applied. A private manager still preserves file encoding handling.
+ final var mgr = FileBuffers.createTextFileBufferManager();
final var path = file.getFullPath();
mgr.connect(path, LocationKind.IFILE, null);
try {
final var buf = mgr.getTextFileBuffer(path, LocationKind.IFILE);
- final IDocument doc = buf != null ? buf.getDocument() : null;
- if (doc == null) {
- return new int[] { 0, 0 };
- }
- final int startLine = Math.max(0, range.getStart().getLine());
- final int startCol = Math.max(0, range.getStart().getCharacter());
- final int endLine = Math.max(0, range.getEnd().getLine());
- final int endCol = Math.max(0, range.getEnd().getCharacter());
- int start = Math.min(doc.getLength(), doc.getLineOffset(startLine) + startCol);
- int end = Math.min(doc.getLength(), doc.getLineOffset(endLine) + endCol);
- if (end < start)
- end = start;
- return new int[] { start, end };
+ return toOffsets(buf != null ? buf.getDocument() : null, range);
} finally {
mgr.disconnect(path, LocationKind.IFILE, null);
}
}
+ private static int[] toOffsets(final IDocument doc, final Range range) throws BadLocationException {
+ if (doc == null)
+ return new int[] { 0, 0 };
+ final int startLine = Math.max(0, range.getStart().getLine());
+ final int startCol = Math.max(0, range.getStart().getCharacter());
+ final int endLine = Math.max(0, range.getEnd().getLine());
+ final int endCol = Math.max(0, range.getEnd().getCharacter());
+ int start = Math.min(doc.getLength(), doc.getLineOffset(startLine) + startCol);
+ int end = Math.min(doc.getLength(), doc.getLineOffset(endLine) + endCol);
+ if (end < start)
+ end = start;
+ return new int[] { start, end };
+ }
+
private MarkdownDiagnosticsManager() {
}
}