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() { } }