diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/DocumentOffsetAsyncCache.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/DocumentOffsetAsyncCache.java new file mode 100644 index 000000000..d519b8d16 --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/internal/DocumentOffsetAsyncCache.java @@ -0,0 +1,141 @@ +/******************************************************************************* + * 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.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentExtension4; + +/** + * Generic, per-document+offset cache for asynchronous results that avoids + * starting the same work twice by sharing a single running task. + * + *

+ * Features: + *

  • Weakly keys by {@link IDocument} to avoid memory leaks. + *
  • Per-document concurrent maps for thread-safe access from UI and + * background. + *
  • Eviction: TTL-based using {@link System#nanoTime()} and document-change + * invalidation when a stable modification stamp is available. + *
  • In-flight de-duplication: only one running task per document+offset. + *
  • Stale-result protection: if the document changes while a value is being + * computed, the result is delivered to callers but is not cached. + */ +public final class DocumentOffsetAsyncCache { + + private record Entry(V value, long createdNanos, long docModStamp) { + boolean stale(final long ttlNanos, final long currentDocStamp) { + return System.nanoTime() - createdNanos > ttlNanos // + // Invalidate when we can confidently detect a document change + || (docModStamp != IDocumentExtension4.UNKNOWN_MODIFICATION_STAMP + && currentDocStamp != IDocumentExtension4.UNKNOWN_MODIFICATION_STAMP + && docModStamp != currentDocStamp); + } + } + + private final Map>> cache = Collections + .synchronizedMap(new WeakHashMap<>()); + private final Map>> inFlight = Collections + .synchronizedMap(new WeakHashMap<>()); + + private final long ttlNanos; + + public DocumentOffsetAsyncCache(final Duration ttl) { + this.ttlNanos = TimeUnit.MILLISECONDS.toNanos(ttl.toMillis()); + } + + /** + * Returns cached value if present and valid; otherwise returns the single + * running task or starts one via {@code supplier}. A value is valid if it has + * not expired by TTL and (when stamps are available) matches the current + * document stamp. Results computed for an older stamp are not cached. + */ + public CompletableFuture computeIfAbsent(final IDocument doc, final int offset, + final Supplier> supplier) { + // Fast path: return a completed future if a fresh value is already cached + final @Nullable V cachedNow = getNow(doc, offset); + if (cachedNow != null) + return CompletableFuture.completedFuture(cachedNow); + + final ConcurrentMap> byOffset = inFlight.computeIfAbsent(doc, + d -> new ConcurrentHashMap<>()); + return byOffset.computeIfAbsent(offset, k -> { + final long startStamp = DocumentUtil.getDocumentModificationStamp(doc); + final CompletableFuture cf = supplier.get(); + cf.whenComplete((v, t) -> { + // Always clean up the in-flight entry by key. Only one future exists + // per offset due to computeIfAbsent, so this is safe and avoids capturing + // a specific future instance. + byOffset.remove(offset); + if (t == null && v != null) { + final long nowStamp = DocumentUtil.getDocumentModificationStamp(doc); + if (startStamp == IDocumentExtension4.UNKNOWN_MODIFICATION_STAMP + || nowStamp == IDocumentExtension4.UNKNOWN_MODIFICATION_STAMP || nowStamp == startStamp) { + put(doc, offset, v); + } + } + }); + return cf; + }); + } + + /** + * @return the cached value if present and valid; removes and returns null if + * TTL expired or the document stamp changed. + */ + public @Nullable V getNow(final IDocument doc, final int offset) { + final ConcurrentMap> byOffset = cache.get(doc); + if (byOffset == null) + return null; + + final Entry e = byOffset.get(offset); + if (e == null) + return null; + + final long nowStamp = DocumentUtil.getDocumentModificationStamp(doc); + if (e.stale(ttlNanos, nowStamp)) { + byOffset.remove(offset, e); + return null; + } + return e.value; + } + + public void invalidate(final IDocument doc) { + cache.remove(doc); // synchronizedMap handles its own locking + final var map = inFlight.remove(doc); // remove returns the per-doc map, if any + if (map != null) { + map.values().forEach(f -> f.cancel(true)); + } + } + + /** + * Stores a value tagged with the current document modification stamp. + */ + public void put(final IDocument doc, final int offset, final V value) { + cache.compute(doc, (d, byOffset) -> { + final ConcurrentMap> map = byOffset != null ? byOffset : new ConcurrentHashMap<>(); + final long stamp = DocumentUtil.getDocumentModificationStamp(doc); + map.put(offset, new Entry<>(value, System.nanoTime(), stamp)); + return map; + }); + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/DeferredOpenDeclarationHyperlink.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/DeferredOpenDeclarationHyperlink.java new file mode 100644 index 000000000..bae1e314b --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/DeferredOpenDeclarationHyperlink.java @@ -0,0 +1,118 @@ +/******************************************************************************* + * 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.operations.declaration; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentExtension4; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.ITextViewer; +import org.eclipse.jface.text.hyperlink.IHyperlink; +import org.eclipse.lsp4e.LanguageServerPlugin; +import org.eclipse.lsp4e.internal.DocumentUtil; + +/** + * An implementation of {@link IHyperlink} which asynchronously opens the link + * once the language server has responded. Opening is dismissed if the editor + * was closed in the meantime, the document was modified, or the response took + * longer than a given timeout. + */ +final class DeferredOpenDeclarationHyperlink implements IHyperlink { + + private static final long DEFERRED_OPEN_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(5); + + private final ITextViewer viewer; + private final IDocument document; + private final long documentInitialModificationStamp; + private final IRegion region; + private final CompletableFuture<@Nullable IHyperlink> future; + private final long createdNanos = System.nanoTime(); + + DeferredOpenDeclarationHyperlink(final ITextViewer viewer, final IDocument document, final IRegion region, + final CompletableFuture<@Nullable IHyperlink> future) { + this.viewer = viewer; + this.document = document; + this.region = region; + this.future = future; + this.documentInitialModificationStamp = DocumentUtil.getDocumentModificationStamp(document); + } + + @Override + public IRegion getHyperlinkRegion() { + return region; + } + + @Override + public @Nullable String getTypeLabel() { + final var link = getResolvedLink(); + return link != null ? link.getTypeLabel() : null; + } + + @Override + public @Nullable String getHyperlinkText() { + final var link = getResolvedLink(); + return link != null ? link.getHyperlinkText() : null; + } + + @Override + public void open() { + future.whenComplete((link, ex) -> { + if (ex != null) { + LanguageServerPlugin.logError(ex.getLocalizedMessage(), ex); + return; + } + final var widget = viewer.getTextWidget(); + if (widget == null) + return; + if (link == null) { + LanguageServerPlugin.logWarning("No hyperlink target resolved for Open Declaration"); //$NON-NLS-1$ + return; + } + widget.getDisplay().asyncExec(() -> { + if (isStale()) + return; + link.open(); + }); + }); + } + + private @Nullable IHyperlink getResolvedLink() { + try { + return future.getNow(null); + } catch (CompletionException ex) { + LanguageServerPlugin.logError(ex.getLocalizedMessage(), ex); + return null; + } + } + + private boolean isStale() { + // LS response came too late? + if (System.nanoTime() - createdNanos > DEFERRED_OPEN_TIMEOUT_NANOS) + return true; + + // Editor was closed? + final var widget = viewer.getTextWidget(); + if (widget == null || widget.isDisposed()) + return true; + + // Document was modified? + if (documentInitialModificationStamp != IDocumentExtension4.UNKNOWN_MODIFICATION_STAMP + && DocumentUtil.getDocumentModificationStamp(document) != documentInitialModificationStamp) + return true; + + return false; + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/DeferredOpenMultiDeclarationHyperlink.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/DeferredOpenMultiDeclarationHyperlink.java new file mode 100644 index 000000000..6517de57e --- /dev/null +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/DeferredOpenMultiDeclarationHyperlink.java @@ -0,0 +1,138 @@ +/******************************************************************************* + * 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.operations.declaration; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentExtension4; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.ITextViewer; +import org.eclipse.jface.text.hyperlink.IHyperlink; +import org.eclipse.jface.viewers.LabelProvider; +import org.eclipse.jface.window.Window; +import org.eclipse.lsp4e.LanguageServerPlugin; +import org.eclipse.lsp4e.internal.DocumentUtil; +import org.eclipse.lsp4e.ui.Messages; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.ui.dialogs.ElementListSelectionDialog; + +/** + * An implementation of {@link IHyperlink} which asynchronously opens a chooser + * of links once the language server has responded. Opening is dismissed if the + * editor was closed in the meantime, the document was modified, or the response + * took longer than a given timeout. + */ +final class DeferredOpenMultiDeclarationHyperlink implements IHyperlink { + + private static final long DEFERRED_OPEN_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(5); + + private final ITextViewer viewer; + private final IDocument document; + private final long documentInitialModificationStamp; + private final IRegion region; + private final CompletableFuture> future; + private final long createdNanos = System.nanoTime(); + + DeferredOpenMultiDeclarationHyperlink(final ITextViewer viewer, final IDocument document, final IRegion region, + final CompletableFuture> future) { + this.viewer = viewer; + this.document = document; + this.region = region; + this.future = future; + this.documentInitialModificationStamp = DocumentUtil.getDocumentModificationStamp(document); + } + + @Override + public IRegion getHyperlinkRegion() { + return region; + } + + @Override + public @Nullable String getTypeLabel() { + return "Open Declaration (resolving...)"; //$NON-NLS-1$ + } + + @Override + public @Nullable String getHyperlinkText() { + return "Open Declaration (resolving...)"; //$NON-NLS-1$ + } + + @Override + public void open() { + future.whenComplete((links, ex) -> { + if (ex != null) { + LanguageServerPlugin.logError(ex.getLocalizedMessage(), ex); + return; + } + final var widget = viewer.getTextWidget(); + if (widget == null) + return; + if (links.isEmpty()) { + LanguageServerPlugin.logWarning("No hyperlink targets resolved for Open Declaration"); //$NON-NLS-1$ + return; + } + widget.getDisplay().asyncExec(() -> { + if (isStale()) + return; + + if (links.size() == 1) { + links.get(0).open(); + return; + } + + final Shell shell = widget.getShell(); + final var dialog = new ElementListSelectionDialog(shell, new LabelProvider() { + @Override + public String getText(final @Nullable Object element) { + if (element instanceof final IHyperlink link) { + final String text = link.getHyperlinkText(); + return text != null ? text : link.getTypeLabel(); + } + return element == null ? "" : element.toString(); //$NON-NLS-1$ + } + }); + dialog.setTitle(Messages.declarationHyperlinkLabel); + dialog.setMessage("Select a target:"); //$NON-NLS-1$ + dialog.setElements(links.toArray()); + dialog.setMultipleSelection(false); + if (dialog.open() == Window.OK) { + Object result = dialog.getFirstResult(); + if (result instanceof IHyperlink link) { + link.open(); + } + } + }); + }); + } + + private boolean isStale() { + // LS response came too late? + if (System.nanoTime() - createdNanos > DEFERRED_OPEN_TIMEOUT_NANOS) + return true; + + // Editor was closed? + final var widget = viewer.getTextWidget(); + if (widget == null || widget.isDisposed()) + return true; + + // Document was modified? + if (documentInitialModificationStamp != IDocumentExtension4.UNKNOWN_MODIFICATION_STAMP + && DocumentUtil.getDocumentModificationStamp(document) != documentInitialModificationStamp) + return true; + + return false; + } +} diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/OpenDeclarationHyperlinkDetector.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/OpenDeclarationHyperlinkDetector.java index b53aef239..e08998172 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/OpenDeclarationHyperlinkDetector.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/OpenDeclarationHyperlinkDetector.java @@ -10,14 +10,15 @@ * Mickael Istria (Red Hat Inc.) - initial implementation * Michał Niewrzał (Rogue Wave Software Inc.) - hyperlink range detection * Lucas Bullen (Red Hat Inc.) - [Bug 517428] Requests sent before initialization + * Sebastian Thomschke (Vegard IT GmbH) - avoid UI freeze when working with slow language servers *******************************************************************************/ package org.eclipse.lsp4e.operations.declaration; -import java.util.Collection; -import java.util.Collections; +import java.time.Duration; import java.util.LinkedHashMap; import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -34,7 +35,7 @@ import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4e.LanguageServerPlugin; import org.eclipse.lsp4e.LanguageServers; -import org.eclipse.lsp4e.internal.Pair; +import org.eclipse.lsp4e.internal.DocumentOffsetAsyncCache; import org.eclipse.lsp4e.ui.Messages; import org.eclipse.lsp4j.Location; import org.eclipse.lsp4j.LocationLink; @@ -45,8 +46,19 @@ public class OpenDeclarationHyperlinkDetector extends AbstractHyperlinkDetector { + private static final long UI_BLOCKING_BUDGET_MS = 200; + + @NonNullByDefault({}) + private static record LabeledLocations(String label, + @Nullable Either, List> locations) { + } + + private static final DocumentOffsetAsyncCache> CACHE = new DocumentOffsetAsyncCache<>( + Duration.ofSeconds(10)); + @Override - public IHyperlink @Nullable [] detectHyperlinks(ITextViewer textViewer, IRegion region, boolean canShowMultipleHyperlinks) { + public IHyperlink @Nullable [] detectHyperlinks(ITextViewer textViewer, IRegion region, + boolean canShowMultipleHyperlinks) { final IDocument document = textViewer.getDocument(); if (document == null) { return null; @@ -58,32 +70,57 @@ public class OpenDeclarationHyperlinkDetector extends AbstractHyperlinkDetector LanguageServerPlugin.logError(e); return null; } - final var allLinks = new LinkedHashMap,LSBasedHyperlink>(); + + final int offset = region.getOffset(); + + final CompletableFuture> request = CACHE.computeIfAbsent(document, offset, () -> { + var definitions = LanguageServers.forDocument(document) + .withCapability(ServerCapabilities::getDefinitionProvider) + .collectAll(ls -> ls.getTextDocumentService().definition(LSPEclipseUtils.toDefinitionParams(params)) + .thenApply(l -> new LabeledLocations(Messages.definitionHyperlinkLabel, l))); + final var declarations = LanguageServers.forDocument(document) + .withCapability(ServerCapabilities::getDeclarationProvider).collectAll( + ls -> ls.getTextDocumentService().declaration(LSPEclipseUtils.toDeclarationParams(params)) + .thenApply(l -> new LabeledLocations(Messages.declarationHyperlinkLabel, l))); + final var typeDefinitions = LanguageServers.forDocument(document) + .withCapability(ServerCapabilities::getTypeDefinitionProvider) + .collectAll(ls -> ls.getTextDocumentService() + .typeDefinition(LSPEclipseUtils.toTypeDefinitionParams(params)) + .thenApply(l -> new LabeledLocations(Messages.typeDefinitionHyperlinkLabel, l))); + final var implementations = LanguageServers.forDocument(document) + .withCapability(ServerCapabilities::getImplementationProvider) + .collectAll(ls -> ls.getTextDocumentService() + .implementation(LSPEclipseUtils.toImplementationParams(params)) + .thenApply(l -> new LabeledLocations(Messages.implementationHyperlinkLabel, l))); + + CompletableFuture> combined = LanguageServers.addAll( + LanguageServers.addAll(LanguageServers.addAll(definitions, declarations), typeDefinitions), + implementations); + return combined.thenApply(locations -> toHyperlinks(document, region, locations)); + }); + try { - var definitions = LanguageServers.forDocument(document).withCapability(ServerCapabilities::getDefinitionProvider) - .collectAll(ls -> ls.getTextDocumentService().definition(LSPEclipseUtils.toDefinitionParams(params)).thenApply(l -> Pair.of(Messages.definitionHyperlinkLabel, l))); - var declarations = LanguageServers.forDocument(document).withCapability(ServerCapabilities::getDeclarationProvider) - .collectAll(ls -> ls.getTextDocumentService().declaration(LSPEclipseUtils.toDeclarationParams(params)).thenApply(l -> Pair.of(Messages.declarationHyperlinkLabel, l))); - var typeDefinitions = LanguageServers.forDocument(document).withCapability(ServerCapabilities::getTypeDefinitionProvider) - .collectAll(ls -> ls.getTextDocumentService().typeDefinition(LSPEclipseUtils.toTypeDefinitionParams(params)).thenApply(l -> Pair.of(Messages.typeDefinitionHyperlinkLabel, l))); - var implementations = LanguageServers.forDocument(document).withCapability(ServerCapabilities::getImplementationProvider) - .collectAll(ls -> ls.getTextDocumentService().implementation(LSPEclipseUtils.toImplementationParams(params)).thenApply(l -> Pair.of(Messages.implementationHyperlinkLabel, l))); - LanguageServers.addAll(LanguageServers.addAll(LanguageServers.addAll(definitions, declarations), typeDefinitions), implementations) - .get(800, TimeUnit.MILLISECONDS) - .stream().flatMap(locations -> toHyperlinks(document, region, locations.first(), locations.second()).stream()) - .forEach(link -> allLinks.putIfAbsent(link.getLocation(), link)); - } catch (ExecutionException e) { - LanguageServerPlugin.logError(e); - } catch (InterruptedException e) { - LanguageServerPlugin.logError(e); + // Try to get a quick result within the UI budget; keep UI responsive. + final List links = request.get(UI_BLOCKING_BUDGET_MS, TimeUnit.MILLISECONDS); + return links.isEmpty() ? null : links.toArray(IHyperlink[]::new); + } catch (final ExecutionException ex) { + LanguageServerPlugin.logError(ex); + } catch (final InterruptedException ex) { + LanguageServerPlugin.logError(ex); Thread.currentThread().interrupt(); - } catch (TimeoutException e) { - LanguageServerPlugin.logWarning("Could not detect hyperlinks due to timeout after 800 milliseconds"); //$NON-NLS-1$ - } - if (allLinks.isEmpty()) { - return null; + } catch (final TimeoutException ex) { + if (canShowMultipleHyperlinks) { + return new IHyperlink[] { new DeferredOpenMultiDeclarationHyperlink(textViewer, document, + findWord(document, region), request) }; + } else { + final CompletableFuture<@Nullable IHyperlink> firstLink = request + .thenApply(links -> !links.isEmpty() ? links.get(0) : null); + return new IHyperlink[] { new DeferredOpenDeclarationHyperlink(textViewer, document, + findWord(document, region), firstLink) }; + } } - return allLinks.values().toArray(IHyperlink[]::new); + + return null; } /** @@ -93,22 +130,27 @@ public class OpenDeclarationHyperlinkDetector extends AbstractHyperlinkDetector * the document * @param linkRegion * the region - * @param locationType - * the location type * @param locations * the LSP locations */ - private static Collection toHyperlinks(IDocument document, IRegion region, - String locationType, @NonNullByDefault({}) Either, List> locations) { - if (locations == null) { - return Collections.emptyList(); + private static List toHyperlinks(final IDocument doc, final IRegion region, + final List locations) { + final var allLinks = new LinkedHashMap, LSBasedHyperlink>(); + for (final LabeledLocations locs : locations) { + final var either = locs.locations(); + if (either == null) + continue; + if (either.isLeft()) { + either.getLeft().stream().filter(Objects::nonNull) + .map(loc -> new LSBasedHyperlink(loc, findWord(doc, region), locs.label())) + .forEach(h -> allLinks.putIfAbsent(h.getLocation(), h)); + } else { + either.getRight().stream().filter(Objects::nonNull).map( + locLink -> new LSBasedHyperlink(locLink, getSelectedRegion(doc, region, locLink), locs.label())) + .forEach(h -> allLinks.putIfAbsent(h.getLocation(), h)); + } } - return locations.map(// - l -> l.stream().filter(Objects::nonNull) - .map(location -> new LSBasedHyperlink(location, findWord(document, region), locationType)) - .toList(), - r -> r.stream().filter(Objects::nonNull).map(locationLink -> new LSBasedHyperlink(locationLink, - getSelectedRegion(document, region, locationLink), locationType)).toList()); + return allLinks.values().stream().toList(); } /**