From 969d79619b86f83b33b6721f5b2afee51f0c1c12 Mon Sep 17 00:00:00 2001 From: Sebastian Thomschke Date: Thu, 13 Nov 2025 10:52:12 +0100 Subject: [PATCH 1/3] perf(highlight): dedupe LSP requests via cache and 75ms debounce --- .../OpenDeclarationHyperlinkDetector.java | 8 +- .../HighlightReconcilingStrategy.java | 82 +++++++++++++++++-- 2 files changed, 80 insertions(+), 10 deletions(-) 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..02de69e2f 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,6 +16,8 @@ import static org.eclipse.lsp4e.internal.NullSafetyHelper.lateNonNull; import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map.Entry; @@ -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,36 @@ 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)); + final CompletableFuture all = CompletableFuture + .allOf(reqs.stream().map(f -> (CompletableFuture) f).toArray(CompletableFuture[]::new)); + return all.thenApply(unused -> { + final var allHighlights = new ArrayList(); + for (final var req : reqs) { + try { + final List highlight = req.join(); + if (highlight != null) { + allHighlights.addAll(highlight); + } + } catch (Exception ex) { + // ignore single-server failures + } + } + return allHighlights; + }); + }); + + request.thenAcceptAsync(highlights -> { if (monitor == null || !monitor.isCanceled()) { updateAnnotations(highlights, sourceViewer.getAnnotationModel()); } - })); + }); } /** @@ -273,6 +320,27 @@ 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) { + return offset; + } + } + void removeOccurrenceAnnotations() { final var sourceViewer = this.sourceViewer; if(sourceViewer == null) From ca975ae6aa6c622f676ce04c753a1f49f733b012 Mon Sep 17 00:00:00 2001 From: Sebastian Thomschke Date: Thu, 13 Nov 2025 11:26:25 +0100 Subject: [PATCH 2/3] refact: simplify HighlightReconcilingStrategy#collectHighlights --- .../HighlightReconcilingStrategy.java | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) 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 02de69e2f..0cb0066cf 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 @@ -17,10 +17,10 @@ import java.net.URI; import java.time.Duration; -import java.util.ArrayList; 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; @@ -84,7 +84,7 @@ public class HighlightReconcilingStrategy private static final int HIGHLIGHT_DEBOUNCE_MS = 75; // Short-lived cache for highlights per document+normalized offset. - private static final DocumentOffsetAsyncCache> HIGHLIGHT_CACHE = + private static final DocumentOffsetAsyncCache> HIGHLIGHT_CACHE = new DocumentOffsetAsyncCache<>(Duration.ofSeconds(10)); // Track the last normalized cache key to avoid canceling identical in-flight work. @@ -229,28 +229,14 @@ private void collectHighlights(int caretOffset, @Nullable IProgressMonitor monit final var params = new DocumentHighlightParams(identifier, position); // Use cache to deduplicate requests to the same symbol for a short period. - final CompletableFuture> request = HIGHLIGHT_CACHE.computeIfAbsent(document, + final CompletableFuture> request = HIGHLIGHT_CACHE.computeIfAbsent(document, cacheKeyOffset, () -> { - final var reqs = requests = LanguageServers.forDocument(document) - .withCapability(ServerCapabilities::getDocumentHighlightProvider) - .computeAll(ls -> ls.getTextDocumentService().documentHighlight(params)); - final CompletableFuture all = CompletableFuture - .allOf(reqs.stream().map(f -> (CompletableFuture) f).toArray(CompletableFuture[]::new)); - return all.thenApply(unused -> { - final var allHighlights = new ArrayList(); - for (final var req : reqs) { - try { - final List highlight = req.join(); - if (highlight != null) { - allHighlights.addAll(highlight); - } - } catch (Exception ex) { - // ignore single-server failures - } - } - return allHighlights; + 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()) { From 386fdc2a7a70fa37c12fc1c4902faec54ab649f3 Mon Sep 17 00:00:00 2001 From: Sebastian Thomschke Date: Thu, 13 Nov 2025 14:57:48 +0100 Subject: [PATCH 3/3] fix: log exception in HighlightReconcilingStrategy#normalizedOffset --- .../lsp4e/operations/highlight/HighlightReconcilingStrategy.java | 1 + 1 file changed, 1 insertion(+) 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 0cb0066cf..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 @@ -323,6 +323,7 @@ private static int normalizedOffset(final IDocument document, final int offset) } return Math.min(docLen, pos + 1); } catch (final BadLocationException ex) { + LanguageServerPlugin.logError(ex.getMessage(), ex); return offset; } }