Skip to content

Commit 4803737

Browse files
authored
perf(highlight): dedupe LSP requests via cache and 75ms debounce (#1383)
1 parent 802ce5b commit 4803737

2 files changed

Lines changed: 67 additions & 10 deletions

File tree

org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/declaration/OpenDeclarationHyperlinkDetector.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,11 @@ private static record LabeledLocations(String label,
7171
return null;
7272
}
7373

74-
final int offset = region.getOffset();
74+
// Normalize cache key to the start of the word to avoid cache misses when the
75+
// mouse moves within the same symbol.
76+
final int cacheKeyOffset = findWord(document, region).getOffset();
7577

76-
final CompletableFuture<List<LSBasedHyperlink>> request = CACHE.computeIfAbsent(document, offset, () -> {
78+
final CompletableFuture<List<LSBasedHyperlink>> request = CACHE.computeIfAbsent(document, cacheKeyOffset, () -> {
7779
final var definitions = LanguageServers.forDocument(document)
7880
.withCapability(ServerCapabilities::getDefinitionProvider)
7981
.collectAll(ls -> ls.getTextDocumentService().definition(LSPEclipseUtils.toDefinitionParams(params))
@@ -98,7 +100,7 @@ private static record LabeledLocations(String label,
98100
.thenApply(l -> new LabeledLocations(Messages.implementationHyperlinkLabel, l))
99101
.exceptionally(err -> new LabeledLocations(Messages.implementationHyperlinkLabel, null)));
100102

101-
CompletableFuture<List<LabeledLocations>> combined = LanguageServers.addAll(
103+
final CompletableFuture<List<LabeledLocations>> combined = LanguageServers.addAll(
102104
LanguageServers.addAll(LanguageServers.addAll(definitions, declarations), typeDefinitions),
103105
implementations);
104106
return combined.thenApply(locations -> toHyperlinks(document, region, locations));

org.eclipse.lsp4e/src/org/eclipse/lsp4e/operations/highlight/HighlightReconcilingStrategy.java

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
import static org.eclipse.lsp4e.internal.NullSafetyHelper.lateNonNull;
1717

1818
import java.net.URI;
19+
import java.time.Duration;
1920
import java.util.HashMap;
2021
import java.util.List;
2122
import java.util.Map.Entry;
23+
import java.util.Objects;
2224
import java.util.concurrent.CompletableFuture;
2325

2426
import org.eclipse.core.runtime.ICoreRunnable;
@@ -51,6 +53,7 @@
5153
import org.eclipse.lsp4e.LSPEclipseUtils;
5254
import org.eclipse.lsp4e.LanguageServerPlugin;
5355
import org.eclipse.lsp4e.LanguageServers;
56+
import org.eclipse.lsp4e.internal.DocumentOffsetAsyncCache;
5457
import org.eclipse.lsp4j.DocumentHighlight;
5558
import org.eclipse.lsp4j.DocumentHighlightKind;
5659
import org.eclipse.lsp4j.DocumentHighlightParams;
@@ -77,6 +80,16 @@ public class HighlightReconcilingStrategy
7780
private @Nullable IDocument document;
7881
private @Nullable Job highlightJob;
7982

83+
// Debounce to avoid flooding requests while the user moves the caret/mouse.
84+
private static final int HIGHLIGHT_DEBOUNCE_MS = 75;
85+
86+
// Short-lived cache for highlights per document+normalized offset.
87+
private static final DocumentOffsetAsyncCache<List<? extends DocumentHighlight>> HIGHLIGHT_CACHE =
88+
new DocumentOffsetAsyncCache<>(Duration.ofSeconds(10));
89+
90+
// Track the last normalized cache key to avoid canceling identical in-flight work.
91+
private int lastCacheKeyOffset = -1;
92+
8093
/**
8194
* Holds the current occurrence annotations.
8295
*/
@@ -119,7 +132,8 @@ private void updateHighlights(ISelection selection) {
119132
}
120133
highlightJob = Job.createSystem("LSP4E Highlight", //$NON-NLS-1$
121134
(ICoreRunnable)(monitor -> collectHighlights(textSelection.getOffset(), monitor)));
122-
highlightJob.schedule();
135+
// Debounce scheduling slightly to coalesce rapid selection changes
136+
highlightJob.schedule(HIGHLIGHT_DEBOUNCE_MS);
123137
}
124138
}
125139

@@ -188,9 +202,20 @@ private void collectHighlights(int caretOffset, @Nullable IProgressMonitor monit
188202
if (sourceViewer == null || document == null || !enabled || monitor != null && monitor.isCanceled()) {
189203
return;
190204
}
191-
cancel();
205+
206+
// Normalize the cache key to the start of the word/symbol.
207+
final int cacheKeyOffset = normalizedOffset(document, caretOffset);
208+
209+
// Only cancel previous requests if the target symbol actually changed.
210+
if (cacheKeyOffset != lastCacheKeyOffset) {
211+
cancel();
212+
lastCacheKeyOffset = cacheKeyOffset;
213+
}
214+
192215
Position position;
193216
try {
217+
// Send the original caret offset to the LS to preserve behavior
218+
// expected by tests and servers that distinguish positions within a word.
194219
position = LSPEclipseUtils.toPosition(caretOffset, document);
195220
} catch (BadLocationException e) {
196221
LanguageServerPlugin.logError(e);
@@ -202,14 +227,22 @@ private void collectHighlights(int caretOffset, @Nullable IProgressMonitor monit
202227
}
203228
final var identifier = LSPEclipseUtils.toTextDocumentIdentifier(uri);
204229
final var params = new DocumentHighlightParams(identifier, position);
205-
requests = LanguageServers.forDocument(document)
206-
.withCapability(ServerCapabilities::getDocumentHighlightProvider)
207-
.computeAll(languageServer -> languageServer.getTextDocumentService().documentHighlight(params));
208-
requests.forEach(request -> request.thenAcceptAsync(highlights -> {
230+
231+
// Use cache to deduplicate requests to the same symbol for a short period.
232+
final CompletableFuture<List<? extends DocumentHighlight>> request = HIGHLIGHT_CACHE.computeIfAbsent(document,
233+
cacheKeyOffset, () -> {
234+
final var reqs = requests = LanguageServers.forDocument(document)
235+
.withCapability(ServerCapabilities::getDocumentHighlightProvider)
236+
.computeAll(ls -> ls.getTextDocumentService().documentHighlight(params));
237+
return CompletableFuture.supplyAsync(() -> reqs.stream().map(CompletableFuture::join) //
238+
.filter(Objects::nonNull).flatMap(List::stream).toList());
239+
});
240+
241+
request.thenAcceptAsync(highlights -> {
209242
if (monitor == null || !monitor.isCanceled()) {
210243
updateAnnotations(highlights, sourceViewer.getAnnotationModel());
211244
}
212-
}));
245+
});
213246
}
214247

215248
/**
@@ -273,6 +306,28 @@ private Object getLockObject(IAnnotationModel annotationModel) {
273306
return annotationModel;
274307
}
275308

309+
/**
310+
* Compute a stable cache key for the symbol at the given caret offset by
311+
* normalizing to the start of the Unicode identifier under the caret.
312+
*/
313+
private static int normalizedOffset(final IDocument document, final int offset) {
314+
try {
315+
// Walk left to the first non-identifier part, then move right one.
316+
int pos = Math.max(0, offset - 1);
317+
final int docLen = document.getLength();
318+
while (pos >= 0 && pos < docLen) {
319+
if (!Character.isUnicodeIdentifierPart(document.getChar(pos))) {
320+
break;
321+
}
322+
pos--;
323+
}
324+
return Math.min(docLen, pos + 1);
325+
} catch (final BadLocationException ex) {
326+
LanguageServerPlugin.logError(ex.getMessage(), ex);
327+
return offset;
328+
}
329+
}
330+
276331
void removeOccurrenceAnnotations() {
277332
final var sourceViewer = this.sourceViewer;
278333
if(sourceViewer == null)

0 commit comments

Comments
 (0)