diff --git a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/completion/ContextInformationTest.java b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/completion/ContextInformationTest.java index e7bb4132c..69da88342 100644 --- a/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/completion/ContextInformationTest.java +++ b/org.eclipse.lsp4e.test/src/org/eclipse/lsp4e/test/completion/ContextInformationTest.java @@ -14,6 +14,7 @@ import static org.junit.Assert.*; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Set; @@ -30,6 +31,8 @@ import org.junit.Before; import org.junit.Test; +import com.google.common.primitives.Chars; + public class ContextInformationTest extends AbstractCompletionTest { @Override @@ -76,8 +79,8 @@ public void testTriggerChars() throws CoreException { final var content = "First"; TestUtils.openTextViewer(TestUtils.createUniqueTestFile(project, content)); - assertArrayEquals(new char[] { 'a', 'b' }, - contentAssistProcessor.getContextInformationAutoActivationCharacters()); + assertEquals(Set.of('a', 'b'), + new HashSet<>(Chars.asList(contentAssistProcessor.getContextInformationAutoActivationCharacters()))); } @Test diff --git a/org.eclipse.lsp4e/META-INF/MANIFEST.MF b/org.eclipse.lsp4e/META-INF/MANIFEST.MF index c005d3cb9..dd461b2b3 100644 --- a/org.eclipse.lsp4e/META-INF/MANIFEST.MF +++ b/org.eclipse.lsp4e/META-INF/MANIFEST.MF @@ -42,7 +42,8 @@ Require-Bundle: org.eclipse.core.runtime;bundle-version="3.12.0", com.google.guava;bundle-version="30.1.0", org.eclipse.e4.core.commands, org.eclipse.compare.core, - org.eclipse.compare + org.eclipse.compare, + io.github.futures4j;bundle-version="[1.0.0,2.0.0)" Bundle-ClassPath: . Bundle-Localization: plugin Bundle-ActivationPolicy: lazy diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/CancellationSupport.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/CancellationSupport.java deleted file mode 100644 index e18eb2e61..000000000 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/CancellationSupport.java +++ /dev/null @@ -1,71 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2303 Red Hat Inc. 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: - * Angelo ZERR (Red Hat Inc.) - initial implementation - *******************************************************************************/ -package org.eclipse.lsp4e.internal; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; - -import org.eclipse.lsp4j.jsonrpc.CancelChecker; - -/** - * LSP cancellation support hosts the list of LSP requests to cancel when a - * process is cancelled (ex: when completion is re-triggered, when hover is give - * up, etc) - * - * @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#cancelRequest - */ -public class CancellationSupport implements CancelChecker { - - private final List> futuresToCancel; - - private boolean cancelled; - - public CancellationSupport() { - this.futuresToCancel = new ArrayList<>(); - this.cancelled = false; - } - - public CompletableFuture execute(CompletableFuture future) { - this.futuresToCancel.add(future); - return future; - } - - /** - * Cancel all LSP requests. - */ - public void cancel() { - this.cancelled = true; - for (CompletableFuture futureToCancel : futuresToCancel) { - if (!futureToCancel.isDone()) { - futureToCancel.cancel(true); - } - } - futuresToCancel.clear(); - } - - @Override - public void checkCanceled() { - // When LSP requests are called (ex : 'textDocument/completion') the LSP - // response - // items are used to compose some UI item (ex : LSP CompletionItem are translate - // to Eclipse ICompletionProposal). - // If the cancel occurs after the call of those LSP requests, the component - // which uses the LSP responses - // can call checkCanceled to stop the UI creation. - if (cancelled) { - throw new CancellationException(); - } - } -} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/MoreCollectors.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/MoreCollectors.java new file mode 100644 index 000000000..1f0592f01 --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/MoreCollectors.java @@ -0,0 +1,34 @@ +/******************************************************************************* + * Copyright (c) 2024 Sebastian Thomschke 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 - initial implementation + *******************************************************************************/ +package org.eclipse.lsp4e.internal; + +import java.util.stream.Collector; + +public class MoreCollectors { + + /** + * @return a collector that efficiently turns a stream of strings into a char + * array + */ + public static Collector toCharArray() { + return Collector.of( // + StringBuilder::new, // supplier + StringBuilder::append, // accumulator + StringBuilder::append, // combiner + sb -> { // finisher + final int length = sb.length(); + final var array = new char[length]; + sb.getChars(0, length, array, 0); + return array; + }); + } +} \ No newline at end of file diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/completion/LSContentAssistProcessor.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/completion/LSContentAssistProcessor.java index 3a56f45cc..b095c4581 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/completion/LSContentAssistProcessor.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/completion/LSContentAssistProcessor.java @@ -8,30 +8,25 @@ * * Contributors: * Mickael Istria (Red Hat Inc.) - initial implementation - * Lucas Bullen (Red Hat Inc.) - Bug 520700 - TextEditors within FormEditors are not supported * Lucas Bullen (Red Hat Inc.) - Refactored for incomplete completion lists - * - Refactored for incomplete completion lists + * Lucas Bullen (Red Hat Inc.) - Bug 520700 - TextEditors within FormEditors are not supported + * Lucas Bullen (Red Hat Inc.) - Refactored for incomplete completion lists + * Sebastian Thomschke - Major refactoring and code cleanup use futures4j *******************************************************************************/ package org.eclipse.lsp4e.operations.completion; import static org.eclipse.lsp4e.internal.ArrayUtil.NO_CHARS; -import static org.eclipse.lsp4e.internal.NullSafetyHelper.castNonNull; import java.net.URI; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.Comparator; -import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; -import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; @@ -47,8 +42,8 @@ import org.eclipse.lsp4e.LanguageServerPlugin; import org.eclipse.lsp4e.LanguageServerWrapper; import org.eclipse.lsp4e.LanguageServers; -import org.eclipse.lsp4e.internal.CancellationSupport; import org.eclipse.lsp4e.internal.CancellationUtil; +import org.eclipse.lsp4e.internal.MoreCollectors; import org.eclipse.lsp4e.ui.Messages; import org.eclipse.lsp4e.ui.UI; import org.eclipse.lsp4j.CompletionItem; @@ -57,40 +52,34 @@ import org.eclipse.lsp4j.CompletionParams; import org.eclipse.lsp4j.SignatureHelpParams; import org.eclipse.lsp4j.SignatureInformation; -import org.eclipse.lsp4j.jsonrpc.CancelChecker; -import org.eclipse.lsp4j.jsonrpc.ResponseErrorException; import org.eclipse.lsp4j.jsonrpc.messages.Either; import org.eclipse.ui.texteditor.ITextEditor; import com.google.common.base.Functions; -import com.google.common.base.Strings; + +import io.github.futures4j.ExtendedFuture; public class LSContentAssistProcessor implements IContentAssistProcessor { + private static final ExtendedFuture NO_CHARS_FUTURE = ExtendedFuture.completedFuture(NO_CHARS); private static final ICompletionProposal[] NO_COMPLETION_PROPOSALS = new ICompletionProposal[0]; + private static final IContextInformation[] NO_CONTEXT_INFORMATION = new IContextInformation[0]; + private static final long TRIGGERS_TIMEOUT = 50; private static final long CONTEXT_INFORMATION_TIMEOUT = 1000; private @Nullable IDocument currentDocument; private @Nullable String errorMessage; + + private ExtendedFuture completionProposalsFuture = ExtendedFuture.completedFuture(null); + private ExtendedFuture> computeContextInformationFuture = ExtendedFuture + .completedFuture(List.of()); + private ExtendedFuture completionTriggerChars = NO_CHARS_FUTURE; + private ExtendedFuture contextTriggerChars = NO_CHARS_FUTURE; + private final boolean errorAsCompletionItem; - private @Nullable CompletableFuture> completionLanguageServersFuture; - private volatile char[] completionTriggerChars = NO_CHARS; - private @Nullable CompletableFuture> contextInformationLanguageServersFuture; - private volatile char[] contextTriggerChars = NO_CHARS; private final boolean incompleteAsCompletionItem; - /** - * The cancellation support used to cancel previous LSP requests - * 'textDocument/completion' when completion is retriggered - */ - private CancellationSupport completionCancellationSupport; - /** - * The cancellation support used to cancel previous LSP requests - * for fetching the trigger characters - */ - private CancellationSupport triggerCharsCancellationSupport; - public LSContentAssistProcessor() { this(true); } @@ -101,8 +90,6 @@ public LSContentAssistProcessor(boolean errorAsCompletionItem) { public LSContentAssistProcessor(boolean errorAsCompletionItem, boolean incompleteAsCompletionItem) { this.errorAsCompletionItem = errorAsCompletionItem; - this.completionCancellationSupport = new CancellationSupport(); - this.triggerCharsCancellationSupport = new CancellationSupport(); this.incompleteAsCompletionItem = incompleteAsCompletionItem; } @@ -121,10 +108,11 @@ public LSContentAssistProcessor(boolean errorAsCompletionItem, boolean incomplet } initiateLanguageServers(document); - CompletionParams param; + CompletionParams param; try { - param = LSPEclipseUtils.toCompletionParams(uri, offset, document, this.completionTriggerChars); + param = LSPEclipseUtils.toCompletionParams(uri, offset, document, + completionTriggerChars.getNowOrFallback(NO_CHARS)); } catch (BadLocationException e) { LanguageServerPlugin.logError(e); this.errorMessage = createErrorMessage(offset, e); @@ -134,37 +122,37 @@ public LSContentAssistProcessor(boolean errorAsCompletionItem, boolean incomplet final var proposals = Collections.synchronizedList(new ArrayList()); final var anyIncomplete = new AtomicBoolean(false); try { - // Cancel the previous LSP requests 'textDocument/completions' and - // completionLanguageServersFuture - this.completionCancellationSupport.cancel(); + // Cancel the previous LSP requests 'textDocument/completions' + this.completionProposalsFuture.cancel(true); // Initialize a new cancel support to register: // - LSP requests 'textDocument/completions' - // - completionLanguageServersFuture - final var cancellationSupport = new CancellationSupport(); - final var completionLanguageServersFuture = this.completionLanguageServersFuture = cancellationSupport.execute( - LanguageServers.forDocument(document).withFilter(capabilities -> capabilities.getCompletionProvider() != null) // - .collectAll((w, ls) -> cancellationSupport.execute(ls.getTextDocumentService().completion(param)) // - .thenAccept(completion -> { - boolean isIncomplete = completion != null && completion.isRight() - && completion.getRight().isIncomplete(); - proposals.addAll(toProposals(document, offset, completion, w, cancellationSupport, - isIncomplete)); - if (isIncomplete) { - anyIncomplete.set(true); - } - }).exceptionally(t -> { - if (!CancellationUtil.isRequestCancelledException(t)) { - LanguageServerPlugin.logError("'%s' LS failed to compute completion items." //$NON-NLS-1$ - .formatted(w.serverDefinition.label), t); - } - return null; - }))); - this.completionCancellationSupport = cancellationSupport; + final var completionProposalsFuture = this.completionProposalsFuture = ExtendedFuture + .from(LanguageServers.forDocument(document) // + .withFilter(capabilities -> capabilities.getCompletionProvider() != null) // + .collectAll((w, ls) -> ExtendedFuture.from(ls.getTextDocumentService().completion(param)) // + .asCancellableByDependents(true) // + .thenAccept(completions -> { + if (completions != null) { + boolean isIncomplete = completions.isRight() + && completions.getRight().isIncomplete(); + proposals.addAll( + toProposals(document, offset, completions, w, isIncomplete)); + if (isIncomplete) { + anyIncomplete.set(true); + } + } + }).exceptionally(t -> { + if (!CancellationUtil.isRequestCancelledException(t)) { + LanguageServerPlugin.logError("'%s' LS failed to compute completion items." //$NON-NLS-1$ + .formatted(w.serverDefinition.label), t); + } + return null; + }))); // Wait for the result of all LSP requests 'textDocument/completions', this // future will be canceled with the next completion - completionLanguageServersFuture.get(); + completionProposalsFuture.get(); } catch (ExecutionException e) { // Ideally exceptions from each LS are handled above and we shouldn't be getting // into this block @@ -219,29 +207,31 @@ private String createErrorMessage(int offset, Exception ex) { private void initiateLanguageServers(IDocument document) { if (currentDocument != document) { currentDocument = document; - triggerCharsCancellationSupport.cancel(); - completionTriggerChars = NO_CHARS; - contextTriggerChars = NO_CHARS; - - completionLanguageServersFuture = triggerCharsCancellationSupport.execute(// - LanguageServers.forDocument(document) + completionTriggerChars.cancel(true); + completionTriggerChars = ExtendedFuture.from(LanguageServers.forDocument(document) // .withFilter(capabilities -> capabilities.getCompletionProvider() != null) // - .collectAll((w, ls) -> { - List triggerChars = castNonNull(w.getServerCapabilities()).getCompletionProvider().getTriggerCharacters(); - completionTriggerChars = mergeTriggers(completionTriggerChars,triggerChars); - return CompletableFuture.completedFuture(null); - })); - contextInformationLanguageServersFuture = triggerCharsCancellationSupport.execute(// - LanguageServers.forDocument(document) + .collectAll((w, ls) -> ExtendedFuture.from(w.getServerCapabilitiesAsync()) // + .asCancellableByDependents(true) // + .thenApply(sc -> sc.getCompletionProvider().getTriggerCharacters()))) + .asCancellableByDependents(true) // + .thenApply(listsOfTriggerChars -> listsOfTriggerChars.stream() // + .flatMap(List::stream) // flatten nested lists + .distinct() // remove duplicates + .collect(MoreCollectors.toCharArray())); + + contextTriggerChars.cancel(true); + contextTriggerChars = ExtendedFuture.from(LanguageServers.forDocument(document) // .withFilter(capabilities -> capabilities.getSignatureHelpProvider() != null) // - .collectAll((w, ls) -> { - List triggerChars = castNonNull(w.getServerCapabilities()).getSignatureHelpProvider().getTriggerCharacters(); - contextTriggerChars = mergeTriggers(contextTriggerChars, triggerChars); - return CompletableFuture.completedFuture(null); - })); + .collectAll((w, ls) -> ExtendedFuture.from(w.getServerCapabilitiesAsync()) // + .asCancellableByDependents(true) // + .thenApply(sc -> sc.getSignatureHelpProvider().getTriggerCharacters()))) + .asCancellableByDependents(true) // + .thenApply(listsOfTriggerChars -> listsOfTriggerChars.stream() // + .flatMap(List::stream) // flatten nested lists + .distinct() // remove duplicates + .collect(MoreCollectors.toCharArray())); } - } private void initiateLanguageServers() { @@ -255,23 +245,22 @@ private void initiateLanguageServers() { } private static List toProposals(IDocument document, int offset, - @Nullable Either, CompletionList> completionList, - LanguageServerWrapper languageServerWrapper, CancelChecker cancelChecker, boolean isIncomplete) { - if (completionList == null) { - return Collections.emptyList(); - } + Either, CompletionList> completions, LanguageServerWrapper lsWrapper, + boolean isIncomplete) { // Stop the compute of ICompletionProposal if the completion has been cancelled - cancelChecker.checkCanceled(); - CompletionItemDefaults defaults = completionList.map(o -> null, CompletionList::getItemDefaults); - return completionList.map( Functions.identity(), CompletionList::getItems).stream() // + if (Thread.interrupted()) + throw new CancellationException(); + CompletionItemDefaults defaults = completions.map(o -> null, CompletionList::getItemDefaults); + return completions.map(Functions.identity(), CompletionList::getItems).stream() // .filter(Objects::nonNull) // - .map(item -> new LSCompletionProposal(document, offset, item, defaults, languageServerWrapper, isIncomplete)) + .map(item -> new LSCompletionProposal(document, offset, item, defaults, lsWrapper, isIncomplete)) .filter(proposal -> { // Stop the compute of ICompletionProposal if the completion has been cancelled - cancelChecker.checkCanceled(); + if (Thread.interrupted()) + throw new CancellationException(); return true; }).filter(proposal -> proposal.validate(document, offset, null)) // - .map(ICompletionProposal.class::cast) + .map(ICompletionProposal.class::cast) // .toList(); } @@ -279,7 +268,7 @@ private static List toProposals(IDocument document, int off public IContextInformation @Nullable [] computeContextInformation(ITextViewer viewer, int offset) { IDocument document = viewer.getDocument(); if (document == null) { - return new IContextInformation[] { /* TODO? show error in context information */ }; + return NO_CONTEXT_INFORMATION; // TODO? show error in context information } initiateLanguageServers(document); SignatureHelpParams param; @@ -287,35 +276,23 @@ private static List toProposals(IDocument document, int off param = LSPEclipseUtils.toSignatureHelpParams(offset, document); } catch (BadLocationException e) { LanguageServerPlugin.logError(e); - return new IContextInformation[] { /* TODO? show error in context information */ }; - } - List contextInformations = Collections.synchronizedList(new ArrayList<>()); - try { - this.contextInformationLanguageServersFuture = LanguageServers.forDocument(document) - .withFilter(capabilities -> capabilities.getSignatureHelpProvider() != null) - .collectAll(ls -> ls.getTextDocumentService().signatureHelp(param).thenAccept(signatureHelp -> { - if (signatureHelp != null) { - signatureHelp.getSignatures().stream().map(LSContentAssistProcessor::toContextInformation) - .forEach(contextInformations::add); - } - })); - this.contextInformationLanguageServersFuture.get(CONTEXT_INFORMATION_TIMEOUT, TimeUnit.MILLISECONDS); - } catch (ResponseErrorException | ExecutionException e) { - if (!CancellationUtil.isRequestCancelledException(e)) { // do not report error if the server has cancelled - // the request - LanguageServerPlugin.logError(e); - } - return new IContextInformation[] { /* TODO? show error in context information */ }; - } catch (InterruptedException e) { - LanguageServerPlugin.logError(e); - Thread.currentThread().interrupt(); - return new IContextInformation[] { /* TODO? show error in context information */ }; - } catch (TimeoutException e) { - LanguageServerPlugin.logWarning("Could not compute context information due to timeout after " //$NON-NLS-1$ - + CONTEXT_INFORMATION_TIMEOUT + " milliseconds", e); //$NON-NLS-1$ - return new IContextInformation[] { /* TODO? show error in context information */ }; + return NO_CONTEXT_INFORMATION; // TODO? show error in context information } - return contextInformations.toArray(IContextInformation[]::new); + + this.computeContextInformationFuture.cancel(true); + final var computeContextInformationFuture = this.computeContextInformationFuture = ExtendedFuture + .from(LanguageServers.forDocument(document) // + .withFilter(capabilities -> capabilities.getSignatureHelpProvider() != null) + .collectAll(ls -> ls.getTextDocumentService().signatureHelp(param))) // + .asCancellableByDependents(true) // + .thenApply(listOfSignatureHelps -> listOfSignatureHelps.stream() // + .map(s -> s.getSignatures()) // access SignatureInformation entries + .flatMap(List::stream) // flatten nested lists + .map(LSContentAssistProcessor::toContextInformation) // + .toList()); + return computeContextInformationFuture + .getOrFallback(CONTEXT_INFORMATION_TIMEOUT, TimeUnit.MILLISECONDS, List.of()) + .toArray(IContextInformation[]::new); } private static IContextInformation toContextInformation(SignatureInformation information) { @@ -327,62 +304,16 @@ private static IContextInformation toContextInformation(SignatureInformation inf return new ContextInformation(information.getLabel(), signature.toString()); } - private void getFuture(@Nullable CompletableFuture future) { - if (future == null) { - return; - } - - try { - future.get(TRIGGERS_TIMEOUT, TimeUnit.MILLISECONDS); - } catch (InterruptedException e) { - LanguageServerPlugin.logError(e); - Thread.currentThread().interrupt(); - } catch (TimeoutException e) { - LanguageServerPlugin.logWarning( - "Could not get trigger characters due to timeout after " + TRIGGERS_TIMEOUT + " milliseconds", e); //$NON-NLS-1$//$NON-NLS-2$ - } catch (OperationCanceledException | ResponseErrorException | ExecutionException | CancellationException e) { - if (!CancellationUtil.isRequestCancelledException(e)) { // do not report error if the server has cancelled - // the request - LanguageServerPlugin.logError(e); - } - } - } - - private static char[] mergeTriggers(char @Nullable [] initialArray, - @Nullable Collection additionalTriggers) { - if (initialArray == null) { - initialArray = NO_CHARS; - } - if (additionalTriggers == null) { - additionalTriggers = Collections.emptySet(); - } - final var triggers = new HashSet(initialArray.length); - for (char c : initialArray) { - triggers.add(c); - } - additionalTriggers.stream().filter(s -> !Strings.isNullOrEmpty(s)).map(triggerChar -> triggerChar.charAt(0)) - .forEach(triggers::add); - final var res = new char[triggers.size()]; - int i = 0; - for (Character c : triggers) { - res[i] = c; - i++; - } - return res; - } - @Override public char @Nullable [] getCompletionProposalAutoActivationCharacters() { initiateLanguageServers(); - getFuture(completionLanguageServersFuture); - return completionTriggerChars; + return completionTriggerChars.getOrFallback(TRIGGERS_TIMEOUT, TimeUnit.MILLISECONDS, NO_CHARS); } @Override public char @Nullable [] getContextInformationAutoActivationCharacters() { initiateLanguageServers(); - getFuture(contextInformationLanguageServersFuture); - return contextTriggerChars; + return contextTriggerChars.getOrFallback(TRIGGERS_TIMEOUT, TimeUnit.MILLISECONDS, NO_CHARS); } @Override diff --git a/repository/category.xml b/repository/category.xml index 55cae8cfc..b5f141006 100644 --- a/repository/category.xml +++ b/repository/category.xml @@ -33,7 +33,7 @@ - + @@ -50,4 +50,7 @@ + + + diff --git a/target-platforms/target-platform-latest/target-platform-latest.target b/target-platforms/target-platform-latest/target-platform-latest.target index 9201fb61e..6a74cc359 100644 --- a/target-platforms/target-platform-latest/target-platform-latest.target +++ b/target-platforms/target-platform-latest/target-platform-latest.target @@ -30,6 +30,22 @@ + + + + futures4j-snapshot + https://raw.githubusercontent.com/futures4j/futures4j/mvn-snapshot-repo + + + + + io.github.futures4j + futures4j + 1.0.0-20240917.100018-3 + jar + + +