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 4b18d4388..063b571c9 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 @@ -71,9 +71,11 @@ private static record LabeledLocations(String label, return null; } - final int offset = region.getOffset(); + // Normalize cache key to the start of the word to avoid cache misses when the + // mouse moves within the same symbol. + final int cacheKeyOffset = findWord(document, region).getOffset(); - final CompletableFuture> request = CACHE.computeIfAbsent(document, offset, () -> { + final CompletableFuture> request = CACHE.computeIfAbsent(document, cacheKeyOffset, () -> { final var definitions = LanguageServers.forDocument(document) .withCapability(ServerCapabilities::getDefinitionProvider) .collectAll(ls -> ls.getTextDocumentService().definition(LSPEclipseUtils.toDefinitionParams(params)) @@ -98,7 +100,7 @@ private static record LabeledLocations(String label, .thenApply(l -> new LabeledLocations(Messages.implementationHyperlinkLabel, l)) .exceptionally(err -> new LabeledLocations(Messages.implementationHyperlinkLabel, null))); - CompletableFuture> combined = LanguageServers.addAll( + final CompletableFuture> combined = LanguageServers.addAll( LanguageServers.addAll(LanguageServers.addAll(definitions, declarations), typeDefinitions), implementations); return combined.thenApply(locations -> toHyperlinks(document, region, locations)); diff --git a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/highlight/HighlightReconcilingStrategy.java b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/highlight/HighlightReconcilingStrategy.java index 2a9dd7361..551ae75fa 100644 --- a/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/highlight/HighlightReconcilingStrategy.java +++ b/org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/highlight/HighlightReconcilingStrategy.java @@ -16,9 +16,11 @@ import static org.eclipse.lsp4e.internal.NullSafetyHelper.lateNonNull; import java.net.URI; +import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map.Entry; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import org.eclipse.core.runtime.ICoreRunnable; @@ -51,6 +53,7 @@ import org.eclipse.lsp4e.LSPEclipseUtils; import org.eclipse.lsp4e.LanguageServerPlugin; import org.eclipse.lsp4e.LanguageServers; +import org.eclipse.lsp4e.internal.DocumentOffsetAsyncCache; import org.eclipse.lsp4j.DocumentHighlight; import org.eclipse.lsp4j.DocumentHighlightKind; import org.eclipse.lsp4j.DocumentHighlightParams; @@ -77,6 +80,16 @@ public class HighlightReconcilingStrategy private @Nullable IDocument document; private @Nullable Job highlightJob; + // Debounce to avoid flooding requests while the user moves the caret/mouse. + private static final int HIGHLIGHT_DEBOUNCE_MS = 75; + + // Short-lived cache for highlights per document+normalized offset. + private static final DocumentOffsetAsyncCache> HIGHLIGHT_CACHE = + new DocumentOffsetAsyncCache<>(Duration.ofSeconds(10)); + + // Track the last normalized cache key to avoid canceling identical in-flight work. + private int lastCacheKeyOffset = -1; + /** * Holds the current occurrence annotations. */ @@ -119,7 +132,8 @@ private void updateHighlights(ISelection selection) { } highlightJob = Job.createSystem("LSP4E Highlight", //$NON-NLS-1$ (ICoreRunnable)(monitor -> collectHighlights(textSelection.getOffset(), monitor))); - highlightJob.schedule(); + // Debounce scheduling slightly to coalesce rapid selection changes + highlightJob.schedule(HIGHLIGHT_DEBOUNCE_MS); } } @@ -188,9 +202,20 @@ private void collectHighlights(int caretOffset, @Nullable IProgressMonitor monit if (sourceViewer == null || document == null || !enabled || monitor != null && monitor.isCanceled()) { return; } - cancel(); + + // Normalize the cache key to the start of the word/symbol. + final int cacheKeyOffset = normalizedOffset(document, caretOffset); + + // Only cancel previous requests if the target symbol actually changed. + if (cacheKeyOffset != lastCacheKeyOffset) { + cancel(); + lastCacheKeyOffset = cacheKeyOffset; + } + Position position; try { + // Send the original caret offset to the LS to preserve behavior + // expected by tests and servers that distinguish positions within a word. position = LSPEclipseUtils.toPosition(caretOffset, document); } catch (BadLocationException e) { LanguageServerPlugin.logError(e); @@ -202,14 +227,22 @@ private void collectHighlights(int caretOffset, @Nullable IProgressMonitor monit } final var identifier = LSPEclipseUtils.toTextDocumentIdentifier(uri); final var params = new DocumentHighlightParams(identifier, position); - requests = LanguageServers.forDocument(document) - .withCapability(ServerCapabilities::getDocumentHighlightProvider) - .computeAll(languageServer -> languageServer.getTextDocumentService().documentHighlight(params)); - requests.forEach(request -> request.thenAcceptAsync(highlights -> { + + // Use cache to deduplicate requests to the same symbol for a short period. + final CompletableFuture> request = HIGHLIGHT_CACHE.computeIfAbsent(document, + cacheKeyOffset, () -> { + final var reqs = requests = LanguageServers.forDocument(document) + .withCapability(ServerCapabilities::getDocumentHighlightProvider) + .computeAll(ls -> ls.getTextDocumentService().documentHighlight(params)); + return CompletableFuture.supplyAsync(() -> reqs.stream().map(CompletableFuture::join) // + .filter(Objects::nonNull).flatMap(List::stream).toList()); + }); + + request.thenAcceptAsync(highlights -> { if (monitor == null || !monitor.isCanceled()) { updateAnnotations(highlights, sourceViewer.getAnnotationModel()); } - })); + }); } /** @@ -273,6 +306,28 @@ private Object getLockObject(IAnnotationModel annotationModel) { return annotationModel; } + /** + * Compute a stable cache key for the symbol at the given caret offset by + * normalizing to the start of the Unicode identifier under the caret. + */ + private static int normalizedOffset(final IDocument document, final int offset) { + try { + // Walk left to the first non-identifier part, then move right one. + int pos = Math.max(0, offset - 1); + final int docLen = document.getLength(); + while (pos >= 0 && pos < docLen) { + if (!Character.isUnicodeIdentifierPart(document.getChar(pos))) { + break; + } + pos--; + } + return Math.min(docLen, pos + 1); + } catch (final BadLocationException ex) { + LanguageServerPlugin.logError(ex.getMessage(), ex); + return offset; + } + } + void removeOccurrenceAnnotations() { final var sourceViewer = this.sourceViewer; if(sourceViewer == null)