diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/internal/AbstractLSPCodeMiningProviderTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/internal/AbstractLSPCodeMiningProviderTest.java new file mode 100644 index 000000000..e71da9f60 --- /dev/null +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/internal/AbstractLSPCodeMiningProviderTest.java @@ -0,0 +1,80 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.test.internal; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextViewer; +import org.eclipse.jface.text.codemining.ICodeMining; +import org.eclipse.lsp4e.internal.AbstractLSPCodeMiningProvider; +import org.eclipse.lsp4e.test.utils.AbstractTestWithProject; +import org.eclipse.lsp4e.test.utils.TestUtils; +import org.eclipse.lsp4j.TextDocumentIdentifier; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link AbstractLSPCodeMiningProvider} cancels the previous + * in-flight request for the same document when a new one starts. + */ +class AbstractLSPCodeMiningProviderTest extends AbstractTestWithProject { + + private static final class StubProvider extends AbstractLSPCodeMiningProvider { + @Override + protected @Nullable CompletableFuture> doProvideCodeMinings(IDocument doc, + TextDocumentIdentifier docId) { + return new CompletableFuture<>(); + } + } + + @Test + void cancelsPreviousRequestForSameDocument() throws Exception { + IFile file = TestUtils.createUniqueTestFile(project, "txt", "content"); + ITextViewer viewer = TestUtils.openTextViewer(file); + + var provider = new StubProvider(); + + var first = provider.provideCodeMinings(viewer, new NullProgressMonitor()); + assertNotNull(first); + assertFalse(first.isCancelled(), "first request should start uncancelled"); + + var second = provider.provideCodeMinings(viewer, new NullProgressMonitor()); + assertNotNull(second); + + assertTrue(first.isCancelled(), "previous request should be cancelled when a new one starts"); + assertFalse(second.isCancelled(), "new request should remain active"); + } + + @Test + void keepsRequestsIndependentAcrossDocuments() throws Exception { + IFile file1 = TestUtils.createUniqueTestFile(project, "txt", "one"); + IFile file2 = TestUtils.createUniqueTestFile(project, "txt", "two"); + ITextViewer viewer1 = TestUtils.openTextViewer(file1); + ITextViewer viewer2 = TestUtils.openTextViewer(file2); + + var provider = new StubProvider(); + + var first = provider.provideCodeMinings(viewer1, new NullProgressMonitor()); + var second = provider.provideCodeMinings(viewer2, new NullProgressMonitor()); + + assertNotNull(first); + assertNotNull(second); + assertFalse(first.isCancelled(), "request for doc1 must remain active when doc2 starts"); + assertFalse(second.isCancelled(), "request for doc2 must start active"); + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/AbstractLSPCodeMiningProvider.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/AbstractLSPCodeMiningProvider.java new file mode 100644 index 000000000..fa5c261b6 --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/AbstractLSPCodeMiningProvider.java @@ -0,0 +1,77 @@ +/******************************************************************************* + * Copyright (c) 2025 Vegard IT GmbH and others. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Sebastian Thomschke (Vegard IT GmbH) - initial implementation. + *******************************************************************************/ +package org.eclipse.lsp4e.internal; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextViewer; +import org.eclipse.jface.text.codemining.AbstractCodeMiningProvider; +import org.eclipse.jface.text.codemining.ICodeMining; +import org.eclipse.lsp4e.LSPEclipseUtils; +import org.eclipse.lsp4j.TextDocumentIdentifier; + +/** + * Base class for LSP-backed code mining providers that: + * + */ +public abstract class AbstractLSPCodeMiningProvider extends AbstractCodeMiningProvider { + + private final ConcurrentMap>> pendingRequests = new ConcurrentHashMap<>(); + + /** + * Computes code minings for the given document. + * + * @return a future producing the list of code minings, or {@code null} if no + * code minings are available + */ + protected abstract @Nullable CompletableFuture> doProvideCodeMinings(IDocument doc, + TextDocumentIdentifier docId); + + @Override + public final @Nullable CompletableFuture> provideCodeMinings(final ITextViewer viewer, + final IProgressMonitor monitor) { + final IDocument document = viewer.getDocument(); + if (document == null) { + return null; + } + + final URI docURI = LSPEclipseUtils.toUri(document); + if (docURI == null) + return null; + + final TextDocumentIdentifier docId = LSPEclipseUtils.toTextDocumentIdentifier(docURI); + + final var current = doProvideCodeMinings(document, docId); + final CompletableFuture> previous; + if (current == null) { + previous = pendingRequests.remove(document); + } else { + previous = pendingRequests.put(document, current); + } + if (previous != null && !previous.isDone()) { + previous.cancel(true); + } + + return current; + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/codelens/CodeLensProvider.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/codelens/CodeLensProvider.java index bf26be5b2..1c682d5c6 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/codelens/CodeLensProvider.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/codelens/CodeLensProvider.java @@ -8,42 +8,36 @@ *******************************************************************************/ package org.eclipse.lsp4e.operations.codelens; -import java.net.URI; import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; -import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; -import org.eclipse.jface.text.ITextViewer; -import org.eclipse.jface.text.codemining.AbstractCodeMiningProvider; import org.eclipse.jface.text.codemining.ICodeMining; -import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4e.LanguageServerPlugin; import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4e.LanguageServers; import org.eclipse.lsp4e.LanguageServers.LanguageServerDocumentExecutor; +import org.eclipse.lsp4e.internal.AbstractLSPCodeMiningProvider; import org.eclipse.lsp4j.CodeLens; import org.eclipse.lsp4j.CodeLensParams; +import org.eclipse.lsp4j.TextDocumentIdentifier; -public class CodeLensProvider extends AbstractCodeMiningProvider { +public class CodeLensProvider extends AbstractLSPCodeMiningProvider { - private @Nullable CompletableFuture> provideCodeMinings(IDocument document) { - URI docURI = LSPEclipseUtils.toUri(document); - if (docURI != null) { - final var param = new CodeLensParams(LSPEclipseUtils.toTextDocumentIdentifier(docURI)); - LanguageServerDocumentExecutor executor = LanguageServers.forDocument(document) - .withFilter(sc -> sc.getCodeLensProvider() != null); - return executor - .collectAll((w, ls) -> ls.getTextDocumentService().codeLens(param) - .thenApply(codeLenses -> LanguageServers.streamSafely(codeLenses) - .map(codeLens -> toCodeMining(document, w, codeLens)).filter(Objects::nonNull))) - .thenApply(result -> result.stream().flatMap(s -> s).toList()); - } else { - return null; - } + @Override + protected @Nullable CompletableFuture> doProvideCodeMinings(IDocument document, + TextDocumentIdentifier docId) { + final var param = new CodeLensParams(docId); + LanguageServerDocumentExecutor executor = LanguageServers.forDocument(document) + .withFilter(sc -> sc.getCodeLensProvider() != null); + return executor + .collectAll((w, ls) -> ls.getTextDocumentService().codeLens(param) + .thenApply(codeLenses -> LanguageServers.streamSafely(codeLenses) + .map(codeLens -> toCodeMining(document, w, codeLens)).filter(Objects::nonNull))) + .thenApply(result -> result.stream().flatMap(s -> s).toList()); } private @Nullable LSPCodeMining toCodeMining(IDocument document, LanguageServerWrapper languageServerWrapper, @@ -58,12 +52,4 @@ public class CodeLensProvider extends AbstractCodeMiningProvider { return null; } } - - @Override - public @Nullable CompletableFuture> provideCodeMinings(ITextViewer viewer, - IProgressMonitor monitor) { - IDocument document = viewer.getDocument(); - return document != null ? provideCodeMinings(document) : null; - } - } diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/color/DocumentColorProvider.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/color/DocumentColorProvider.java index 30f380a72..6f4c3dc74 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/color/DocumentColorProvider.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/color/DocumentColorProvider.java @@ -11,7 +11,6 @@ */ package org.eclipse.lsp4e.operations.color; -import java.net.URI; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -19,17 +18,14 @@ import java.util.concurrent.CompletableFuture; import java.util.function.Function; -import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; -import org.eclipse.jface.text.ITextViewer; -import org.eclipse.jface.text.codemining.AbstractCodeMiningProvider; import org.eclipse.jface.text.codemining.ICodeMining; -import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4e.LanguageServerPlugin; import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.internal.AbstractLSPCodeMiningProvider; import org.eclipse.lsp4j.ColorInformation; import org.eclipse.lsp4j.DocumentColorParams; import org.eclipse.lsp4j.ServerCapabilities; @@ -41,9 +37,8 @@ /** * Consume the 'textDocument/documentColor' request to decorate color references * in the editor. - * */ -public class DocumentColorProvider extends AbstractCodeMiningProvider { +public class DocumentColorProvider extends AbstractLSPCodeMiningProvider { private final Map colorTable; @@ -51,24 +46,19 @@ public DocumentColorProvider() { colorTable = new HashMap<>(); } - private @Nullable CompletableFuture> provideCodeMinings(IDocument document) { - URI docURI = LSPEclipseUtils.toUri(document); - - if (docURI != null) { - final var textDocumentIdentifier = LSPEclipseUtils.toTextDocumentIdentifier(docURI); - final var param = new DocumentColorParams(textDocumentIdentifier); - return LanguageServers.forDocument(document) - .withCapability(ServerCapabilities::getColorProvider) - .collectAll( - // Need to do some of the result processing inside the function we supply to collectAll(...) - // as need the LSW to construct the ColorInformationMining - (wrapper, ls) -> ls.getTextDocumentService().documentColor(param) - .thenApply(colors -> LanguageServers.streamSafely(colors) - .map(color -> toMining(color, document, textDocumentIdentifier, wrapper)))) - .thenApply(res -> res.stream().flatMap(Function.identity()).filter(Objects::nonNull).toList()); - } else { - return null; - } + @Override + protected @Nullable CompletableFuture> doProvideCodeMinings(IDocument document, + TextDocumentIdentifier docId) { + final var param = new DocumentColorParams(docId); + return LanguageServers.forDocument(document) + .withCapability(ServerCapabilities::getColorProvider) + .collectAll( + // Need to do some of the result processing inside the function we supply to collectAll(...) + // as need the LSW to construct the ColorInformationMining + (wrapper, ls) -> ls.getTextDocumentService().documentColor(param) + .thenApply(colors -> LanguageServers.streamSafely(colors) + .map(color -> toMining(color, document, docId, wrapper)))) + .thenApply(res -> res.stream().flatMap(Function.identity()).filter(Objects::nonNull).toList()); } private @Nullable ColorInformationMining toMining(ColorInformation color, IDocument document, TextDocumentIdentifier textDocumentIdentifier, LanguageServerWrapper wrapper) { @@ -82,13 +72,6 @@ public DocumentColorProvider() { return null; } - @Override - public @Nullable CompletableFuture> provideCodeMinings(ITextViewer viewer, - IProgressMonitor monitor) { - IDocument document = viewer.getDocument(); - return document != null ? provideCodeMinings(document) : null; - } - /** * Returns the color from the given rgba. * diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/inlayhint/InlayHintProvider.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/inlayhint/InlayHintProvider.java index 949f55f62..c93c57e31 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/inlayhint/InlayHintProvider.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/inlayhint/InlayHintProvider.java @@ -8,7 +8,6 @@ *******************************************************************************/ package org.eclipse.lsp4e.operations.inlayhint; -import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -16,60 +15,56 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; -import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; -import org.eclipse.jface.text.ITextViewer; -import org.eclipse.jface.text.codemining.AbstractCodeMiningProvider; import org.eclipse.jface.text.codemining.ICodeMining; import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4e.LanguageServerPlugin; import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.internal.AbstractLSPCodeMiningProvider; import org.eclipse.lsp4e.internal.CancellationUtil; import org.eclipse.lsp4j.InlayHint; import org.eclipse.lsp4j.InlayHintParams; import org.eclipse.lsp4j.Position; import org.eclipse.lsp4j.Range; import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.TextDocumentIdentifier; -public class InlayHintProvider extends AbstractCodeMiningProvider { +public class InlayHintProvider extends AbstractLSPCodeMiningProvider { - private @Nullable CompletableFuture> provideCodeMinings(IDocument document) { - URI docURI = LSPEclipseUtils.toUri(document); - if (docURI != null) { - // Eclipse seems to request minings only when the document is loaded (or changed), rather than - // whenever the viewport [displayed area] changes, so request minings for the whole document in one go. - var end = new Position(0,0); - try { - end = LSPEclipseUtils.toPosition(document.getLength(), document); - } catch (BadLocationException e) { - LanguageServerPlugin.logWarning("Unable to compute end of document", e); //$NON-NLS-1$ - } - final var viewPortRange = new Range(new Position(0,0), end); - final var param = new InlayHintParams(LSPEclipseUtils.toTextDocumentIdentifier(docURI), viewPortRange); - List inlayHintResults = Collections.synchronizedList(new ArrayList<>()); - return LanguageServers.forDocument(document).withCapability(ServerCapabilities::getInlayHintProvider) - .collectAll((w, ls) -> ls.getTextDocumentService() // - .inlayHint(param).exceptionally((ex -> { - if (!(ex instanceof CancellationException || CancellationUtil.isRequestCancelledException(ex))) { - LanguageServerPlugin.logError(ex); - } - return Collections.emptyList(); - })) // - .thenAcceptAsync(inlayHints -> { - // textDocument/inlayHint may return null - if (inlayHints != null) { - inlayHints.stream().filter(Objects::nonNull) - .map(inlayHint -> toCodeMining(document, w, inlayHint)) - .filter(Objects::nonNull) - .forEach(inlayHintResults::add); - } - })).thenApplyAsync(theVoid -> inlayHintResults); - } else { - return null; + @Override + protected @Nullable CompletableFuture> doProvideCodeMinings(IDocument document, + TextDocumentIdentifier docId) { + // Eclipse seems to request minings only when the document is loaded (or changed), rather than + // whenever the viewport [displayed area] changes, so request minings for the whole document in one go. + var end = new Position(0,0); + try { + end = LSPEclipseUtils.toPosition(document.getLength(), document); + } catch (BadLocationException e) { + LanguageServerPlugin.logWarning("Unable to compute end of document", e); //$NON-NLS-1$ } + final var viewPortRange = new Range(new Position(0,0), end); + final var param = new InlayHintParams(docId, viewPortRange); + List inlayHintResults = Collections.synchronizedList(new ArrayList<>()); + return LanguageServers.forDocument(document).withCapability(ServerCapabilities::getInlayHintProvider) + .collectAll((w, ls) -> ls.getTextDocumentService() // + .inlayHint(param).exceptionally((ex -> { + if (!(ex instanceof CancellationException || CancellationUtil.isRequestCancelledException(ex))) { + LanguageServerPlugin.logError(ex); + } + return Collections.emptyList(); + })) // + .thenAcceptAsync(inlayHints -> { + // textDocument/inlayHint may return null + if (inlayHints != null) { + inlayHints.stream().filter(Objects::nonNull) + .map(inlayHint -> toCodeMining(document, w, inlayHint)) + .filter(Objects::nonNull) + .forEach(inlayHintResults::add); + } + })).thenApplyAsync(theVoid -> inlayHintResults); } private @Nullable LSPLineContentCodeMining toCodeMining(IDocument document, LanguageServerWrapper languageServerWrapper, @@ -81,12 +76,4 @@ public class InlayHintProvider extends AbstractCodeMiningProvider { return null; } } - - @Override - public @Nullable CompletableFuture> provideCodeMinings(ITextViewer viewer, - IProgressMonitor monitor) { - IDocument document = viewer.getDocument(); - return document != null ? provideCodeMinings(document) : null; - } - }