1616import static org .eclipse .lsp4e .internal .NullSafetyHelper .lateNonNull ;
1717
1818import java .net .URI ;
19+ import java .time .Duration ;
1920import java .util .HashMap ;
2021import java .util .List ;
2122import java .util .Map .Entry ;
23+ import java .util .Objects ;
2224import java .util .concurrent .CompletableFuture ;
2325
2426import org .eclipse .core .runtime .ICoreRunnable ;
5153import org .eclipse .lsp4e .LSPEclipseUtils ;
5254import org .eclipse .lsp4e .LanguageServerPlugin ;
5355import org .eclipse .lsp4e .LanguageServers ;
56+ import org .eclipse .lsp4e .internal .DocumentOffsetAsyncCache ;
5457import org .eclipse .lsp4j .DocumentHighlight ;
5558import org .eclipse .lsp4j .DocumentHighlightKind ;
5659import 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